diff --git a/.github/workflows/AGENTS.md b/.github/workflows/AGENTS.md new file mode 100644 index 0000000..2375bbd --- /dev/null +++ b/.github/workflows/AGENTS.md @@ -0,0 +1,59 @@ +# AGENTS.md + +GitHub Actions workflows for CI, release automation, and policy checks. + +## Scope + +- This file governs edits in `.github/workflows/*.yml`. +- Keep one top-level triggered workflow per CI intent: + - `feature-ci.yml` for non-`master` push CI + - `master-ci.yml` for merged-PR-to-`master` release CI +- Keep OS/platform splits in reusable workflows invoked via `workflow_call`. + +## Current workflow map + +- `feature-ci.yml` -> orchestrator for feature branch CI. +- `feature-linux.yml` -> reusable Linux build/test/package workflow. +- `feature-windows.yml` -> reusable Windows build/test/package workflow. +- `master-pr-title-guard.yml` -> validates semantic-prefix policy for PRs targeting `master`. +- `master-ci.yml` -> orchestrator for merged PRs into `master`. +- `master-windows.yml` -> reusable Windows build/test/package workflow for master release flow. + +## CI invariants (do not break) + +1. Keep workflow intent stable: + - feature workflows produce branch+sha artifacts + - master workflows produce semver-tagged release artifacts +2. Do not duplicate top-level triggers for the same intent (avoid split runs in Actions UI). +3. Preserve artifact naming conventions unless docs are updated in the same change: + - `ccm3-linux-.zip` + - `ccm3-windows-.zip` + - `ccm3-windows-installer-` (feature) and `.exe` (master release asset) +4. Preserve embedded app version wiring via `-DCCM_APP_VERSION=...` in both feature and master build flows. +5. Keep `master` release flow gated to merged PRs only. +6. Keep PR title prefix policy aligned with `scripts/compute_master_semver.sh`. + +## Editing rules + +- Prefer minimal, surgical edits; avoid large workflow rewrites unless requested. +- Reusable workflows should declare explicit `workflow_call` inputs for required context (e.g., version, merge SHA). +- Keep `permissions` least-privilege: + - reusable build workflows: `contents: read` + - release/tag orchestrator: `contents: write` +- Keep shells consistent with runner setup: + - Linux steps use standard bash-compatible shell. + - Windows MSYS2 jobs keep `shell: msys2 {0}` and run `msys2/setup-msys2` before MSYS2 commands. + +## Required follow-ups + +- After changing workflow topology, update `docs/ci-cd-guide.md`. +- If artifact names or release behavior change, update `docs/ci-cd-guide.md` and `docs/versioning.md` if needed. +- If semver prefix policy changes, update both `master-pr-title-guard.yml` and docs. +- If adding/removing workflow files, update this file's "Current workflow map". + +## Anti-patterns + +- Do not add a second top-level feature or master trigger file. +- Do not move release creation into a reusable workflow that lacks `contents: write`. +- Do not introduce MSVC-specific build commands; project CI targets Clang/MinGW toolchains. +- Do not silently change artifact names; downstream release/download steps depend on exact names. diff --git a/.github/workflows/feature-ci.yml b/.github/workflows/feature-ci.yml new file mode 100644 index 0000000..3f265bc --- /dev/null +++ b/.github/workflows/feature-ci.yml @@ -0,0 +1,18 @@ +name: Feature CI + +on: + push: + branches-ignore: + - master + +permissions: + contents: read + +jobs: + linux: + name: Linux build + tests + uses: ./.github/workflows/feature-linux.yml + + windows: + name: Windows build + tests + uses: ./.github/workflows/feature-windows.yml diff --git a/.github/workflows/feature-linux.yml b/.github/workflows/feature-linux.yml new file mode 100644 index 0000000..91de283 --- /dev/null +++ b/.github/workflows/feature-linux.yml @@ -0,0 +1,87 @@ +name: Feature Linux Build, Test, and Package + +on: + workflow_call: + +permissions: + contents: read + +jobs: + build-linux: + name: Linux build + tests + runs-on: ubuntu-latest + env: + CCACHE_DIR: ${{ github.workspace }}/.ccache + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Compute feature version + run: | + VERSION="$(bash scripts/compute_feature_version.sh "${GITHUB_REF_NAME}" "${GITHUB_SHA}")" + echo "VERSION=${VERSION}" >> "$GITHUB_ENV" + + - name: Install build tools + run: > + sudo apt-get update && + sudo apt-get install -y + ninja-build + ccache + pkg-config + libgtk-3-dev + libwxgtk3.2-dev + + - name: Restore Linux compiler cache + uses: actions/cache@v4 + with: + path: ${{ env.CCACHE_DIR }} + key: ccache-linux-${{ github.ref_name }}-v1 + restore-keys: | + ccache-linux-${{ github.ref_name }}- + ccache-linux- + + - name: Configure + run: > + cmake -S . -B build -G Ninja + -DCCM_BUILD_TESTS=ON + -DCCM_USE_SYSTEM_WX=ON + -DCCM_APP_VERSION="${VERSION}" + -DCMAKE_C_COMPILER_LAUNCHER=ccache + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache + + - name: Build + run: cmake --build build --parallel 3 + + - name: Run unit tests + run: ctest --test-dir build --output-on-failure + + - name: Remove test executable from bundle + run: rm -f build/bin/ccm_core_tests + + - name: Verify runtime dependencies are resolved + run: | + for f in build/bin/*; do + [ -f "$f" ] || continue + if file "$f" | grep -q "ELF"; then + echo "Checking $f" + ldd "$f" + if ldd "$f" | grep -q "not found"; then + echo "Missing runtime dependency detected in $f" + exit 1 + fi + fi + done + + - name: Stage Linux artifact folder + run: | + rm -rf dist/ccm3-linux + mkdir -p dist/ccm3-linux + cp -a build/bin/. dist/ccm3-linux/ + + - name: Upload Linux artifact + uses: actions/upload-artifact@v4 + with: + name: ccm3-linux-${{ env.VERSION }}.zip + path: dist/ccm3-linux + if-no-files-found: error diff --git a/.github/workflows/feature-windows.yml b/.github/workflows/feature-windows.yml new file mode 100644 index 0000000..38d1a7d --- /dev/null +++ b/.github/workflows/feature-windows.yml @@ -0,0 +1,122 @@ +name: Feature Windows Build, Test, and Package + +on: + workflow_call: + +permissions: + contents: read + +jobs: + build-windows: + name: Windows build + tests + runs-on: windows-latest + env: + CCACHE_DIR_WIN: ${{ github.workspace }}\.ccache + + defaults: + run: + shell: msys2 {0} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup MSYS2 (MinGW-w64 UCRT64) + uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + cache: true + install: >- + mingw-w64-ucrt-x86_64-gcc + mingw-w64-ucrt-x86_64-cmake + mingw-w64-ucrt-x86_64-ninja + mingw-w64-ucrt-x86_64-make + mingw-w64-ucrt-x86_64-ccache + mingw-w64-ucrt-x86_64-wxwidgets3.2-msw + mingw-w64-ucrt-x86_64-nsis + + - name: Compute feature version + run: | + VERSION="$(bash scripts/compute_feature_version.sh "${GITHUB_REF_NAME}" "${GITHUB_SHA}")" + echo "VERSION=${VERSION}" >> "$GITHUB_ENV" + + - name: Restore Windows compiler cache + uses: actions/cache@v4 + with: + path: ${{ env.CCACHE_DIR_WIN }} + key: ccache-windows-${{ github.ref_name }}-v2 + restore-keys: | + ccache-windows-${{ github.ref_name }}- + ccache-windows- + + - name: Configure MSYS2 ccache directory + run: | + CCACHE_DIR_POSIX="$(cygpath -u "$CCACHE_DIR_WIN")" + echo "CCACHE_DIR=${CCACHE_DIR_POSIX}" >> "$GITHUB_ENV" + mkdir -p "$CCACHE_DIR_POSIX" + + - name: Configure + run: > + cmake -S . -B build -G Ninja + -DCMAKE_BUILD_TYPE=Release + -DCCM_BUILD_TESTS=ON + -DCCM_USE_SYSTEM_WX=ON + -DCCM_APP_VERSION="${VERSION}" + -DCMAKE_C_COMPILER_LAUNCHER=ccache + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache + + - name: Build + run: cmake --build build --parallel 4 + + - name: Run unit tests + run: ctest --test-dir build --output-on-failure + + - name: Collect runtime DLL dependencies + run: | + rm -f deps.txt + for f in build/bin/*.exe build/bin/*.dll; do + [ -e "$f" ] || continue + ldd "$f" | awk '/\/ucrt64\/bin\// { print $3 }' >> deps.txt + done + sort -u deps.txt | while read -r dep; do + [ -f "$dep" ] && cp -n "$dep" build/bin/ + done + + - name: Verify runtime dependencies are resolved + run: | + for f in build/bin/*.exe build/bin/*.dll; do + [ -e "$f" ] || continue + echo "Checking $f" + ldd "$f" + if ldd "$f" | grep -q "not found"; then + echo "Missing runtime dependency detected in $f" + exit 1 + fi + done + + - name: Remove test executable from bundle + run: rm -f build/bin/ccm_core_tests.exe build/bin/ccm_core_tests + + - name: Build Windows installer + run: makensis -DAPP_VERSION="${VERSION}" scripts/installer.nsi + + - name: Stage Windows artifact folder + run: | + rm -rf dist/ccm3-windows + mkdir -p dist/ccm3-windows + cp -a build/bin/. dist/ccm3-windows/ + + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + name: ccm3-windows-${{ env.VERSION }}.zip + path: dist/ccm3-windows + if-no-files-found: error + + - name: Upload Windows installer artifact + uses: actions/upload-artifact@v4 + with: + name: ccm3-windows-installer-${{ env.VERSION }} + path: ccm3-windows-installer.exe + if-no-files-found: error diff --git a/.github/workflows/master-ci.yml b/.github/workflows/master-ci.yml new file mode 100644 index 0000000..04afe0d --- /dev/null +++ b/.github/workflows/master-ci.yml @@ -0,0 +1,71 @@ +name: Master CI + +on: + pull_request: + branches: + - master + types: + - closed + +permissions: + contents: write + +jobs: + compute-version: + name: Determine semantic version + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + release_tag: ${{ steps.version.outputs.release_tag }} + steps: + - name: Checkout tags + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve semantic version + id: version + run: bash scripts/compute_master_semver.sh "${{ github.event.pull_request.title }}" + + build-windows: + name: Windows build + tests + needs: compute-version + uses: ./.github/workflows/master-windows.yml + with: + version: ${{ needs.compute-version.outputs.version }} + merge_commit_sha: ${{ github.event.pull_request.merge_commit_sha }} + + release-master: + name: Tag and release on master + needs: + - compute-version + - build-windows + runs-on: ubuntu-latest + steps: + - name: Download Windows artifact + uses: actions/download-artifact@v4 + with: + name: ccm3-windows-${{ needs.compute-version.outputs.version }}.zip + path: release/windows + + - name: Download Windows installer artifact + uses: actions/download-artifact@v4 + with: + name: ccm3-windows-installer-${{ needs.compute-version.outputs.version }} + path: release/installer + + - name: Package release assets with semantic version + run: | + mkdir -p release-assets + VERSION="${{ needs.compute-version.outputs.version }}" + zip -r "release-assets/ccm3-windows-${VERSION}.zip" release/windows + cp release/installer/ccm3-windows-installer.exe "release-assets/ccm3-windows-installer-${VERSION}.exe" + + - name: Create tag and GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.compute-version.outputs.release_tag }} + target_commitish: ${{ github.event.pull_request.merge_commit_sha }} + generate_release_notes: true + files: release-assets/* diff --git a/.github/workflows/master-pr-title-guard.yml b/.github/workflows/master-pr-title-guard.yml new file mode 100644 index 0000000..f61d94b --- /dev/null +++ b/.github/workflows/master-pr-title-guard.yml @@ -0,0 +1,33 @@ +name: Master PR Title Guard + +on: + pull_request: + branches: + - master + types: + - opened + - edited + - synchronize + - reopened + - ready_for_review + +permissions: + contents: read + +jobs: + validate-title: + name: Require semver prefix in PR title + runs-on: ubuntu-latest + steps: + - name: Validate pull request title prefix + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + title_lower="$(echo "${PR_TITLE}" | tr '[:upper:]' '[:lower:]')" + if [[ "${title_lower}" == major* || "${title_lower}" == minor* || "${title_lower}" == fix* || "${title_lower}" == patch* || "${title_lower}" == path* ]]; then + echo "PR title is valid: ${PR_TITLE}" + exit 0 + fi + echo "Invalid PR title: '${PR_TITLE}'" + echo "Title must start with one of: major, minor, fix, patch, or path." + exit 1 diff --git a/.github/workflows/master-windows.yml b/.github/workflows/master-windows.yml new file mode 100644 index 0000000..cc79cc8 --- /dev/null +++ b/.github/workflows/master-windows.yml @@ -0,0 +1,128 @@ +name: Master Windows Build, Test, and Package + +on: + workflow_call: + inputs: + version: + required: true + type: string + merge_commit_sha: + required: true + type: string + +permissions: + contents: read + +jobs: + build-windows: + name: Windows build + tests + runs-on: windows-latest + env: + CCACHE_DIR_WIN: ${{ github.workspace }}\.ccache + VERSION: ${{ inputs.version }} + + defaults: + run: + shell: msys2 {0} + + steps: + - name: Checkout merge commit + uses: actions/checkout@v4 + with: + ref: ${{ inputs.merge_commit_sha }} + + - name: Setup MSYS2 (MinGW-w64 UCRT64) + uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + cache: true + install: >- + mingw-w64-ucrt-x86_64-gcc + mingw-w64-ucrt-x86_64-cmake + mingw-w64-ucrt-x86_64-ninja + mingw-w64-ucrt-x86_64-make + mingw-w64-ucrt-x86_64-ccache + mingw-w64-ucrt-x86_64-wxwidgets3.2-msw + mingw-w64-ucrt-x86_64-nsis + + - name: Restore Windows compiler cache + uses: actions/cache@v4 + with: + path: ${{ env.CCACHE_DIR_WIN }} + key: ccache-windows-master-v2 + restore-keys: | + ccache-windows-master- + ccache-windows- + + - name: Configure MSYS2 ccache directory + run: | + CCACHE_DIR_POSIX="$(cygpath -u "$CCACHE_DIR_WIN")" + echo "CCACHE_DIR=${CCACHE_DIR_POSIX}" >> "$GITHUB_ENV" + mkdir -p "$CCACHE_DIR_POSIX" + + - name: Configure + run: > + cmake -S . -B build -G Ninja + -DCMAKE_BUILD_TYPE=Release + -DCCM_BUILD_TESTS=ON + -DCCM_USE_SYSTEM_WX=ON + -DCCM_APP_VERSION="${VERSION}" + -DCMAKE_C_COMPILER_LAUNCHER=ccache + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache + + - name: Build + run: cmake --build build --parallel 4 + + - name: Run unit tests + run: ctest --test-dir build --output-on-failure + + - name: Collect runtime DLL dependencies + run: | + rm -f deps.txt + for f in build/bin/*.exe build/bin/*.dll; do + [ -e "$f" ] || continue + ldd "$f" | awk '/\/ucrt64\/bin\// { print $3 }' >> deps.txt + done + sort -u deps.txt | while read -r dep; do + [ -f "$dep" ] && cp -n "$dep" build/bin/ + done + + - name: Verify runtime dependencies are resolved + run: | + for f in build/bin/*.exe build/bin/*.dll; do + [ -e "$f" ] || continue + echo "Checking $f" + ldd "$f" + if ldd "$f" | grep -q "not found"; then + echo "Missing runtime dependency detected in $f" + exit 1 + fi + done + + - name: Remove test executable from bundle + run: rm -f build/bin/ccm_core_tests.exe build/bin/ccm_core_tests + + - name: Build Windows installer + run: makensis -DAPP_VERSION="${VERSION}" scripts/installer.nsi + + - name: Stage Windows artifact folder + run: | + rm -rf dist/ccm3-windows + mkdir -p dist/ccm3-windows + cp -a build/bin/. dist/ccm3-windows/ + + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + name: ccm3-windows-${{ env.VERSION }}.zip + path: dist/ccm3-windows + if-no-files-found: error + + - name: Upload Windows installer artifact + uses: actions/upload-artifact@v4 + with: + name: ccm3-windows-installer-${{ env.VERSION }} + path: ccm3-windows-installer.exe + if-no-files-found: error + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..af5c0e5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,104 @@ +# AGENTS.md + +C++ desktop implementation (originally based on a Tauri Rust+TS version) — single wxWidgets binary built with CMake + FetchContent. + +## Project structure + +- `core/` — `ccm_core` static library. UI-agnostic domain, ports, services, infra adapters. **Never** depends on wxWidgets. See `core/AGENTS.md`. +- `ui_wx/` — `ccm_ui_wx` static library. The only place that touches wxWidgets. See `ui_wx/AGENTS.md`. +- `app/` — `ccm` executable (composition root). Wires concrete adapters into services. See `app/AGENTS.md`. +- `tests/` — `ccm_core_tests` doctest binary. Pure-logic tests against in-memory fakes. See `tests/AGENTS.md`. +- `docs/` — long-form developer documentation. Start with `docs/adding-a-new-game.md` for the canonical end-to-end procedure for extending the app with a new TCG. See `docs/AGENTS.md`. +- `.github/workflows/` — GitHub Actions CI/release workflows. See `.github/workflows/AGENTS.md` for orchestrator/reusable workflow rules and CI invariants. +- `cmake/` — `Toolchain.cmake` (Clang first, MinGW-w64 fallback), `Dependencies.cmake` (FetchContent pins), `CompilerWarnings.cmake` (`ccm_warnings` interface target). +- `CMakeLists.txt` — top-level. Defines options `CCM_USE_SYSTEM_WX` (default OFF) and `CCM_BUILD_TESTS` (default ON). + - Build metadata option: `CCM_APP_VERSION` (defaults to `${PROJECT_VERSION} (localbuild)` for local/manual builds, overridden by CI). + +## Architecture rules (do not break) + +1. Dependencies point inward only: `app` -> `ui_wx` -> `core`. `core` depends on no other first-party target. +2. `core` must not include any wx header. CI-equivalent: `rg "wx/" core/` must return zero hits. +3. Cross-boundary types are domain types and `ccm::ui::AppContext`. UI code consumes services via the references in `AppContext` — **never** by including a concrete adapter header. +4. Errors cross port boundaries as `ccm::Result` (see `core/include/ccm/util/Result.hpp`). Do not throw across ports; reserve exceptions for genuinely unrecoverable bugs. +5. JSON layout must stay byte-for-byte stable: aliases `releaseDate`, `setNo`, `firstEdition`, `dataStorage`, `defaultGame`, and the `signed` JSON key (mapped to C++ field `signed_`). If you touch a domain type, update the round-trip test in `tests/domain_json_tests.cpp`. + +## Toolchain + +- **Compilers**: Clang 14+ preferred, MinGW-w64 GCC 11+ fallback on Windows. **Do not** add MSVC support. +- **C++ standard**: C++20 (`CMAKE_CXX_STANDARD 20`, `CXX_EXTENSIONS OFF`). +- **Build system**: CMake 3.22+ with `FetchContent`. Pin every dep by tag in `cmake/Dependencies.cmake`; never use `master`. + +## Key dependencies + +| Library | Version | Purpose | +|---|---|---| +| nlohmann/json | v3.11.3 | All JSON serde | +| libcpr/cpr | 1.10.5 | HTTPS (libcurl built in-tree, Schannel on Windows) | +| wxWidgets | v3.2.5 | UI toolkit (only `ui_wx/` may use it) | +| doctest | v2.4.11 | Tests (only when `CCM_BUILD_TESTS=ON`) | + +## Commands + +Run from the **workspace root**. + +- Configure (Clang/Ninja, FetchContent wx): + `cmake -S . -B build -G Ninja` +- Configure (Windows MinGW-w64 fallback — verified working with MSYS2 UCRT64 GCC 15.2 + CMake 4.x): + `cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release` +- Configure with system wx for fast iteration: + `cmake -S . -B build -G Ninja -DCCM_USE_SYSTEM_WX=ON` +- Build everything: + `cmake --build build --parallel` +- Run the app: + `./build/bin/ccm3` (`.\build\bin\ccm3.exe` on Windows) +- Run tests (CCM_BUILD_TESTS defaults to ON): + `ctest --test-dir build --output-on-failure` — current baseline: **86 cases / 211 assertions, all green**. +- Build tests only: + `cmake --build build --target ccm_core_tests` + +> **Windows runtime note**: `cpr` is built as a shared library, so `build/bin/` ends up with `libcpr.dll`, `libcurl.dll`, `libzlib.dll` next to `ccm.exe`. With MinGW-w64 you also need `libgcc_s_seh-1.dll` and `libstdc++-6.dll` from your MSYS2 UCRT64 `bin/` on `PATH` (or copied alongside the exe) to launch from Explorer. +> +> **Windows rebuild note**: linking `ccm.exe` fails with `Permission denied` if the app is still running/locked. Close `ccm.exe` before rebuilding app targets. +> +> **Windows cold-start note**: first launch right after a fresh build is often slower than subsequent launches due to cold file cache and Windows security scanning (Defender/SmartScreen) on the new exe/dll set. Warm launches are the meaningful baseline for app-side perf changes. + +## UI performance guardrails + +- Keep first paint responsive: avoid heavy synchronous work in constructors of top-level windows/dialogs. +- For startup, defer non-critical work with `CallAfter(...)` so the frame appears before data loading. +- Preserve "select first row on startup" behavior without blocking first paint by scheduling the initial selection with `CallAfter(...)` instead of selecting synchronously during row rebuild. +- Avoid repeated set-list loads when opening Add/Edit: cache Magic sets in `MainFrame` and reuse them in `CardEditDialog`. +- Pass preloaded set data to dialogs by pointer/reference, not by value, to avoid copying large vectors on every open. +- While constructing/populating dialogs with many controls/choices, wrap with `Freeze()`/`Thaw()` and append choice items in bulk (`wxArrayString`) to reduce layout/repaint churn. +- Keep selected-card preview usable when remote lookup fails: show a per-game card-back fallback image (CCM2 parity), not a blank preview panel. + +## Windows UI theming guardrails + +- `wxWidgets` native dark-mode behavior on Windows is inconsistent across controls and OS builds; prefer explicit app theming in `ui_wx/src/Theme.cpp` plus targeted native hints only where needed. +- Treat UI text from domain/services as UTF-8 and convert explicitly at wx boundaries (`wxString::FromUTF8(...)` for display, `ToStdString(wxConvUTF8)` for write-back); do not rely on implicit `std::string` conversions on Windows. +- For dialogs (`wxDialog`) and frames (`wxFrame`), apply title-bar dark mode through top-level-window handling (not frame-only handling), otherwise modal window headers stay light. +- The `wxListCtrl` native header can ignore dark hints; if native theming is unreliable, use a custom themed header row and preserve key UX parity (single-click sort, edge-drag resize, divider double-click autosize). +- Do **not** apply `Explorer` class theming to `wxTextCtrl` in dark mode; some Windows builds force black typed text. Keep edit controls palette-driven, and for critical fields (for example the top-right filter box) enforce colors through `WM_CTLCOLOREDIT` handling in `MainFrame` when needed. +- Theme modal dialogs explicitly before `ShowModal()` (Settings, Create/Edit, image viewer, etc.) so they don't inherit mismatched defaults from Windows. +- For button hover/pressed contrast fixes in dark theme, prefer explicit state handling in `Theme.cpp`; native Windows button states can override wx colors and produce unreadable white-on-white combinations. +- Keep button theming state dynamic across theme switches (Dark <-> Light). Avoid lambdas that permanently capture old theme colors or behavior; stale handlers can make light-mode buttons look wrong. +- After changing `ui_wx` theming behavior, rebuild the final app target (`cmake --build build --target ccm --parallel`), not just `ccm_ui_wx`, before validating runtime behavior. +- If linker fails with `Permission denied` on `build/bin/ccm.exe`, the app is still running; close it before rebuilding. + +## Required follow-ups + +- After modifying a domain type's fields or JSON layout you **must** update the matching round-trip test in `tests/domain_json_tests.cpp` and re-run tests. +- After adding a new `.cpp` to `core/` or `ui_wx/` you **must** add it to that package's `CMakeLists.txt`. There is no glob. +- After adding a new dependency you **must** verify its license is compatible with this repository's MIT license before merging. +- After adding a new game module you **must**: (1) extend `Game` enum + string mappings in `core/include/ccm/domain/Enums.hpp`, (2) register the module in `app/main.cpp`, (3) add a directory mapping in `app/main.cpp::dirNameForGame`, (4) implement an `IGameView` derived class (or `GameView`) and add it to `AppContext::gameViews` in the composition root. +- After changing the per-game seams (`IGameModule`, `IGameView`, the `BaseCard*Panel` template hooks) you **must** update `docs/adding-a-new-game.md` so the canonical "add a new game" walkthrough stays in sync with the code. +- After changing `formatTextForFs` or `parseIndexFromFilename` you **must** update `tests/fs_names_tests.cpp` — these functions exist to stay byte-compatible with the original Rust `util/fs.rs`. + +## Anti-patterns + +- Don't include `wx/...` headers from `core/` (breaks layering and tests will refuse to build). +- Don't add tests that hit real network or real disk; use the fake `ccm::testing::InMemoryFileSystem` and the existing http/source fakes. +- Don't enable `-Wconversion` / `-Wsign-conversion`; they fight wxWidgets's `int` IDs. They were intentionally removed from `cmake/CompilerWarnings.cmake`. +- Don't use `master` for FetchContent tags. Bump deliberately. +- Don't bump `cpr` past `1.10.5` without re-doing the curl install/export plumbing: cpr 1.11.x adds `install(EXPORT cprTargets)` rules that reference `libcurl_shared`, which isn't in any export set when curl is built as a sub-project, breaking configure. The 1.10.5 + `HAVE_IOCTLSOCKET_FIONBIO=ON` workaround in `cmake/Dependencies.cmake` is the verified MinGW-w64 path — do not remove it without an end-to-end Windows build first. +- Don't create multiple top-level triggers for the same CI intent (feature or master). Keep one triggered orchestrator workflow (`feature-ci.yml`, `master-ci.yml`) and use `workflow_call` reusable workflows for OS-specific splits so GitHub Actions stays a single run per intent. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..305c95c --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,58 @@ +cmake_minimum_required(VERSION 3.22) + +# Detect the toolchain BEFORE project() so we can pick clang or fall back to +# MinGW-w64 g++ on Windows. This file is no-op on non-Windows hosts. +include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/Toolchain.cmake) + +project(ccm + VERSION 0.1.0 + DESCRIPTION "Card Collection Manager 3 - C++/wxWidgets desktop app" + LANGUAGES CXX +) + +if(WIN32) + enable_language(RC) +endif() + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) +endif() + +# Generate compile_commands.json for tooling (clangd, ccls, etc.). +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# All built artifacts land in a predictable place. +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) +set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) +set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) + +# Project-wide options. +option(CCM_USE_SYSTEM_WX "Use a system-installed wxWidgets via find_package instead of FetchContent" OFF) +option(CCM_BUILD_TESTS "Build unit tests for ccm_core" ON) +set(CCM_APP_VERSION "${PROJECT_VERSION} (localbuild)" CACHE STRING "Application version string embedded in UI") + +include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/CompilerWarnings.cmake) +include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/Dependencies.cmake) + +add_subdirectory(core) +add_subdirectory(ui_wx) +add_subdirectory(app) + +if(CCM_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() + +message(STATUS "") +message(STATUS "============================================================") +message(STATUS " ${PROJECT_NAME} ${PROJECT_VERSION}") +message(STATUS " Compiler: ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}") +message(STATUS " Build type: ${CMAKE_BUILD_TYPE}") +message(STATUS " System wx: ${CCM_USE_SYSTEM_WX}") +message(STATUS " Build tests: ${CCM_BUILD_TESTS}") +message(STATUS "============================================================") +message(STATUS "") diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ab99c0a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Card Collection Manager 3 contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..171e772 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# Card Collection Manager 3 + +Card Collection Manager 3 is an extensible desktop application for managing trading card game collections. It is designed as a practical way to track cards and manage per-card images for large collections, with local per-game data, set synchronization workflows, and a desktop-first UX. The app preserves the established JSON layout from earlier CCM versions so existing collections stay compatible. + +## Screenshots + +### Magic The Gathering + +![CCM3 Demo - Magic: The Gathering](docs/assets/images/demo-mtg.png) + +### Pokemon TCG + +![CCM3 Demo - Pokemon](docs/assets/images/demo-pkm.png) + +## Migrating From CCM1 And CCM2 + +CCM3 reads the established collection layout, so data from both CCM1 and CCM2 can be copied into the configured CCM3 data directory. + +Basic migration flow: + +1. Close any running CCM app instance that points to the same data folder. +2. Copy your existing game data into the matching CCM3 game folder: + - from CCM1: export/copy your collection JSON and images into the corresponding CCM3 game directory. + - from CCM2: copy per-game `collection.json` and `images/` directories into the same per-game folders in CCM3. +3. Start CCM3 and confirm the data directory in `File > Settings`. + +If your files are in the expected layout, collections should load without conversion. + +## Technical Overview + +This is a C++20 project with a wxWidgets UI: + +- `core/`: domain logic, services, and infrastructure adapters +- `ui_wx/`: wxWidgets presentation layer +- `app/`: executable composition root + +Key libraries used by the project: + +- `cpr` (libcurl-based): REST/HTTP calls +- `nlohmann/json`: JSON serialization/deserialization +- `doctest`: unit testing + +Dependency direction is strict: `app -> ui_wx -> core`. + +## Documentation + +Implementation details now live in `docs/`. Start here: + +- [Documentation Index](docs/README.md) +- [Build Locally Guide](docs/dow-doc-build-locally.md) +- [Intro For New Developers](docs/intro-to-new-developers.md) +- [CI/CD Guide](docs/ci-cd-guide.md) +- [Versioning Guide](docs/versioning.md) +- [Testing Guide And Test Code Of Conduct](docs/testing-and-test-code-of-conduct.md) +- [Adding A New Game To Card Collection Manager](docs/adding-a-new-game.md) + +## Previous Implementations + +This project continues earlier versions of Card Collection Manager: + +- [Card Collection Manager (CCM1)](https://github.com/sebastiandine/Card-Collection-Manager): original Java/Swing implementation. +- [Card Collection Manager 2 (CCM2)](https://github.com/sebastiandine/Card-Collection-Manager-2): Rust/TypeScript (Tauri) rewrite with multi-game support. + +## License + +This project is licensed under the [MIT License](LICENSE). + +Third-party dependencies and assets remain under their respective licenses. diff --git a/app/AGENTS.md b/app/AGENTS.md new file mode 100644 index 0000000..0dd578b --- /dev/null +++ b/app/AGENTS.md @@ -0,0 +1,35 @@ +# app/AGENTS.md + +The `ccm` executable — composition root only. The single place where concrete adapter types are mentioned. Read the root `AGENTS.md` first. + +## File pointers + +- `main.cpp` — the entire app. Defines `CcmApp : public wxApp`, builds the dependency graph in `OnInit()`, then hands an `AppContext` to `MainFrame`. +- `CMakeLists.txt` — declares the `ccm` target. Sets `WIN32_EXECUTABLE TRUE` on Windows so no console window appears. Links `ccm_core`, `ccm_ui_wx`, `ccm_warnings`. + +## Conventions + +1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`, `MagicGameModule`, `PokemonGameModule`, `MagicGameView`, `PokemonGameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here. +2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually. +3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set). +4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`). +5. **`config.json` location** is the executable's parent directory, resolved via `wxStandardPaths::Get().GetExecutablePath()`. Do not change this — existing installations rely on that location. +6. **Image format handlers** must be registered via `wxImage::AddHandler(new wxPNGHandler)` and `new wxJPEGHandler` before any image is loaded. They are added in `OnInit()` first thing — keep it that way. +7. **Card preview source ownership** lives inside the `IGameModule`. The composition root never constructs an `CardPreviewSource` directly; it calls `previewSvc_->registerModule(*Mod_)` and the service pulls the module's preview source via `IGameModule::cardPreviewSource()` (returning `nullptr` is silently skipped). + +## Required follow-ups + +- After adding a new game module you **must**: (1) add a `unique_ptr<GameModule>` member in declaration-order-correct position, (2) construct it in `OnInit()`, (3) call `setSvc_->registerModule(Mod_.get())`, (4) call `previewSvc_->registerModule(*Mod_)` (no-op when the module has no preview source), (5) extend `dirNameForGame`, (6) add a typed `JsonCollectionRepository<Card>` + `CollectionService<Card>` if the game has a custom card type, (7) construct a `GameView` and append its raw pointer to the `AppContext::gameViews` vector, (8) make sure the view's `unique_ptr<>` member sits **after** all its deps (typed services + `IGameModule`). +- After adding a new core service you **must** add a `unique_ptr<...>` member, construct it in `OnInit()` after its deps, and add a reference field to `AppContext`. +- After adding a new dependency edge you **must** verify destruction order is still correct: deps **before** dependents in the member list. +- After changing the IGameView contract or the AppContext shape, update `docs/adding-a-new-game.md` so the canonical procedure stays in sync. + +## Anti-patterns + +- Don't add business logic here. If something is more than `std::make_unique` and a `register/Bind` call, it belongs in `core/`. +- Don't construct services on the stack inside `OnInit()` — they must outlive the `MainFrame`, so they live as `CcmApp` members. + +## Commands + +- Build the binary: `cmake --build build --target ccm` +- Run on Windows / MinGW-w64: `.\build\bin\ccm.exe`. The cpr/curl/zlib DLLs are placed next to the exe automatically; the MSYS2 UCRT64 runtime (`libgcc_s_seh-1.dll`, `libstdc++-6.dll`) needs to be on `PATH` (e.g. `P:\msys2\msys64\ucrt64\bin`). On verified runs the exe loads under window title "Card Collection Manager 3". diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt new file mode 100644 index 0000000..8eee632 --- /dev/null +++ b/app/CMakeLists.txt @@ -0,0 +1,20 @@ +# ccm: thin executable / composition root. +# Wires concrete adapters into the services and hands them to the UI. + +add_executable(ccm + main.cpp +) +set_target_properties(ccm PROPERTIES OUTPUT_NAME ccm3) + +# Subsystem WINDOWS on Windows so we don't get a stray console. +if(WIN32) + target_sources(ccm PRIVATE ccm.rc) + set_target_properties(ccm PROPERTIES WIN32_EXECUTABLE TRUE) +endif() + +target_link_libraries(ccm + PRIVATE + ccm_core + ccm_ui_wx + ccm_warnings +) diff --git a/app/ccm.rc b/app/ccm.rc new file mode 100644 index 0000000..a2d3b30 --- /dev/null +++ b/app/ccm.rc @@ -0,0 +1 @@ +ccm_main_icon ICON "resources/ccm.ico" diff --git a/app/main.cpp b/app/main.cpp new file mode 100644 index 0000000..3cb1eb7 --- /dev/null +++ b/app/main.cpp @@ -0,0 +1,145 @@ +// Composition root: builds the dependency graph from the bottom up and hands +// it to the wxWidgets UI layer. This is the only place where concrete adapter +// types are mentioned - everything downstream depends on interfaces. + +#include "ccm/domain/MagicCard.hpp" +#include "ccm/domain/PokemonCard.hpp" +#include "ccm/games/magic/MagicGameModule.hpp" +#include "ccm/games/pokemon/PokemonGameModule.hpp" +#include "ccm/infra/CprHttpClient.hpp" +#include "ccm/infra/JsonCollectionRepository.hpp" +#include "ccm/infra/JsonSetRepository.hpp" +#include "ccm/infra/LocalImageStore.hpp" +#include "ccm/infra/StdFileSystem.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/AppContext.hpp" +#include "ccm/ui/MagicGameView.hpp" +#include "ccm/ui/MainFrame.hpp" +#include "ccm/ui/PokemonGameView.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +// All Game::X -> directory string mappings live in one place. Eliminates the +// need for the repositories to know about concrete game module classes. +std::string dirNameForGame(ccm::Game g) { + switch (g) { + case ccm::Game::Magic: return "magic"; + case ccm::Game::Pokemon: return "pokemon"; + } + return "magic"; +} + +} // namespace + +class CcmApp : public wxApp { +public: + bool OnInit() override { + // wxImage knows about PNG/JPEG once these handlers are registered. + wxImage::AddHandler(new wxPNGHandler); + wxImage::AddHandler(new wxJPEGHandler); + + // --- locate config next to exe, data in user home ---------------- + const std::filesystem::path exeDir = + std::filesystem::path(wxStandardPaths::Get().GetExecutablePath().ToStdString()) + .parent_path(); + const auto configPath = exeDir / "config.json"; + const auto defaultDataDir = + std::filesystem::path(wxGetHomeDir().ToStdString()) / "ccm3-data"; + + // --- build the dependency graph ----------------------------------- + fs_ = std::make_unique(); + config_ = std::make_unique(*fs_, configPath, defaultDataDir); + + if (auto init = config_->initialize(); !init) { + wxMessageBox("Failed to load configuration: " + init.error(), + "Startup error", wxOK | wxICON_ERROR); + return false; + } + + http_ = std::make_unique(); + magicMod_ = std::make_unique(*http_); + pokeMod_ = std::make_unique(*http_); + + magicRepo_ = std::make_unique>( + *fs_, *config_, &dirNameForGame); + pokeRepo_ = std::make_unique>( + *fs_, *config_, &dirNameForGame); + setRepo_ = std::make_unique(*fs_, *config_, &dirNameForGame); + imgStore_ = std::make_unique(*fs_, *config_, &dirNameForGame); + + imgSvc_ = std::make_unique(*imgStore_); + magicCollSvc_ = std::make_unique>( + *magicRepo_, *imgStore_); + pokeCollSvc_ = std::make_unique>( + *pokeRepo_, *imgStore_); + setSvc_ = std::make_unique(*setRepo_); + setSvc_->registerModule(magicMod_.get()); + setSvc_->registerModule(pokeMod_.get()); + + previewSvc_ = std::make_unique(*http_); + previewSvc_->registerModule(*magicMod_); + previewSvc_->registerModule(*pokeMod_); + + // Per-game UI bundles. Order here is the order shown in the Game menu. + magicView_ = std::make_unique( + *config_, *magicCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *magicMod_); + pokeView_ = std::make_unique( + *config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_); + + ctx_ = std::make_unique(ccm::ui::AppContext{ + *config_, + *setSvc_, + *imgSvc_, + *previewSvc_, + *magicMod_, + *pokeMod_, + { magicView_.get(), pokeView_.get() }, + }); + + auto* frame = new ccm::ui::MainFrame(*ctx_); +#ifdef __WXMSW__ + frame->SetIcon(wxICON(ccm_main_icon)); +#endif + frame->Show(true); + return true; + } + +private: + // Order matters - destruction is reverse, so put services that depend on + // others *after* their deps in the member list. Game views are torn down + // first so their panels release any references to the typed services. + std::unique_ptr fs_; + std::unique_ptr config_; + std::unique_ptr http_; + std::unique_ptr magicMod_; + std::unique_ptr pokeMod_; + std::unique_ptr> magicRepo_; + std::unique_ptr> pokeRepo_; + std::unique_ptr setRepo_; + std::unique_ptr imgStore_; + std::unique_ptr imgSvc_; + std::unique_ptr> magicCollSvc_; + std::unique_ptr> pokeCollSvc_; + std::unique_ptr setSvc_; + std::unique_ptr previewSvc_; + std::unique_ptr magicView_; + std::unique_ptr pokeView_; + std::unique_ptr ctx_; +}; + +wxIMPLEMENT_APP(CcmApp); diff --git a/app/resources/ccm.ico b/app/resources/ccm.ico new file mode 100644 index 0000000..27384e4 Binary files /dev/null and b/app/resources/ccm.ico differ diff --git a/cmake/CompilerWarnings.cmake b/cmake/CompilerWarnings.cmake new file mode 100644 index 0000000..2dbb5e3 --- /dev/null +++ b/cmake/CompilerWarnings.cmake @@ -0,0 +1,33 @@ +# Shared warning configuration as an INTERFACE library so each first-party +# target can opt in with `target_link_libraries( PRIVATE ccm_warnings)` +# without duplicating flags. + +add_library(ccm_warnings INTERFACE) + +set(_CCM_GCC_CLANG_FLAGS + -Wall + -Wextra + -Wpedantic + -Wshadow + -Wnon-virtual-dtor + -Wcast-align + -Wunused + -Woverloaded-virtual + -Wnull-dereference + -Wdouble-promotion + -Wformat=2 + # -Wconversion / -Wsign-conversion are intentionally NOT enabled - they + # fight badly with wxWidgets's wxWindowID = int API surface and the + # noise-to-signal ratio doesn't justify it for a single-binary app. +) + +if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") + target_compile_options(ccm_warnings INTERFACE ${_CCM_GCC_CLANG_FLAGS}) +elseif(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") + target_compile_options(ccm_warnings INTERFACE /W4 /permissive-) +endif() + +# UTF-8 source/exec encoding everywhere. +if(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") + target_compile_options(ccm_warnings INTERFACE /utf-8) +endif() diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake new file mode 100644 index 0000000..6e455f8 --- /dev/null +++ b/cmake/Dependencies.cmake @@ -0,0 +1,145 @@ +# Third-party dependencies pulled via FetchContent. Pinning to known-good tags +# is intentional - bump deliberately, never use `master`. + +include(FetchContent) + +# Avoid hammering the network on every reconfigure once content is downloaded. +set(FETCHCONTENT_UPDATES_DISCONNECTED ON CACHE BOOL "" FORCE) + +# CMake 4 dropped compatibility with cmake_minimum_required(VERSION < 3.5). +# Some pinned tags below (doctest 2.4.11, nlohmann/json 3.11.3, wxWidgets 3.2.5) +# still ship a `cmake_minimum_required(VERSION 3.0/3.1)` and would otherwise +# fail to configure. Override the floor for all FetchContent'd subprojects. +# See: https://cmake.org/cmake/help/latest/variable/CMAKE_POLICY_VERSION_MINIMUM.html +if(NOT DEFINED CMAKE_POLICY_VERSION_MINIMUM) + set(CMAKE_POLICY_VERSION_MINIMUM 3.5) +endif() + +# --------------------------------------------------------------------------- +# nlohmann/json - JSON (de)serialization. Header-only. +# --------------------------------------------------------------------------- +message(STATUS "[ccm] Fetching nlohmann/json ...") +FetchContent_Declare( + nlohmann_json + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG v3.11.3 + GIT_SHALLOW TRUE +) +set(JSON_BuildTests OFF CACHE INTERNAL "") +set(JSON_Install OFF CACHE INTERNAL "") +FetchContent_MakeAvailable(nlohmann_json) + +# --------------------------------------------------------------------------- +# cpr - C++ Requests (libcurl wrapper). Builds curl in-tree so we don't need +# a system libcurl. Used for Scryfall + pokemontcg.io REST calls. +# +# Pinned at 1.10.5 deliberately. 1.11.x adds an `install(EXPORT cprTargets)` +# rule that references `libcurl_shared`, which isn't in any export set when +# curl is built as a sub-project here - configure fails with: +# "install(EXPORT \"cprTargets\" ...) includes target \"cpr\" which requires +# target \"libcurl_shared\" that is not in any export set." +# Bumping past 1.10.5 needs additional plumbing (either disable cpr's install +# rules or move curl to its own export set). Verified end-to-end on MSYS2 +# UCRT64 GCC 15.2 + CMake 4.3 with this pin. +# --------------------------------------------------------------------------- +message(STATUS "[ccm] Fetching libcpr/cpr (this also fetches a curl source tree) ...") +FetchContent_Declare( + cpr + GIT_REPOSITORY https://github.com/libcpr/cpr.git + GIT_TAG 1.10.5 + GIT_SHALLOW TRUE +) +set(CPR_USE_SYSTEM_CURL OFF CACHE INTERNAL "") +set(CPR_BUILD_TESTS OFF CACHE INTERNAL "") +set(CPR_ENABLE_SSL ON CACHE INTERNAL "") +# Let cpr fetch & build a matching curl, with a Windows-friendly SSL backend. +set(CPR_FORCE_USE_SYSTEM_CURL OFF CACHE INTERNAL "") + +# Trim the curl build aggressively. We only need HTTPS GET, so anything else +# is dead weight at best and a MinGW-w64 link-failure source at worst. +set(BUILD_CURL_EXE OFF CACHE INTERNAL "") +set(BUILD_TESTING OFF CACHE INTERNAL "") +set(CURL_DISABLE_TESTS ON CACHE INTERNAL "") +set(CURL_DISABLE_DOCS ON CACHE INTERNAL "") +set(CURL_DISABLE_INSTALL ON CACHE INTERNAL "") +set(CURL_DISABLE_LDAP ON CACHE INTERNAL "") +set(CURL_DISABLE_LDAPS ON CACHE INTERNAL "") +set(CURL_DISABLE_FTP ON CACHE INTERNAL "") +set(CURL_DISABLE_TELNET ON CACHE INTERNAL "") +set(CURL_DISABLE_DICT ON CACHE INTERNAL "") +set(CURL_DISABLE_FILE ON CACHE INTERNAL "") +set(CURL_DISABLE_TFTP ON CACHE INTERNAL "") +set(CURL_DISABLE_GOPHER ON CACHE INTERNAL "") +set(CURL_DISABLE_IMAP ON CACHE INTERNAL "") +set(CURL_DISABLE_POP3 ON CACHE INTERNAL "") +set(CURL_DISABLE_SMB ON CACHE INTERNAL "") +set(CURL_DISABLE_SMTP ON CACHE INTERNAL "") +set(CURL_DISABLE_RTSP ON CACHE INTERNAL "") +set(CURL_DISABLE_MQTT ON CACHE INTERNAL "") + +if(WIN32) + # Use the OS-provided TLS stack so we don't need OpenSSL on the build host. + set(CURL_USE_SCHANNEL ON CACHE INTERNAL "") + set(CMAKE_USE_SCHANNEL ON CACHE INTERNAL "") + set(CURL_USE_OPENSSL OFF CACHE INTERNAL "") + + # Workaround: curl's CMake compile-test for ioctlsocket(FIONBIO) sometimes + # fails on MinGW-w64 UCRT64 due to winsock2.h header ordering, which then + # makes nonblock.c #error out with "no non-blocking method was found". + # Force-set the macro so the build proceeds; ioctlsocket+FIONBIO is the + # canonical Win32 API for non-blocking sockets and is always available. + set(HAVE_IOCTLSOCKET_FIONBIO ON CACHE INTERNAL "") +endif() +FetchContent_MakeAvailable(cpr) + +# --------------------------------------------------------------------------- +# wxWidgets - cross-platform UI toolkit. +# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# doctest - lightweight, header-only unit testing framework. Only fetched when +# tests are enabled, so a default release build doesn't pull it. +# --------------------------------------------------------------------------- +if(CCM_BUILD_TESTS) + message(STATUS "[ccm] Fetching doctest ...") + FetchContent_Declare( + doctest + GIT_REPOSITORY https://github.com/doctest/doctest.git + GIT_TAG v2.4.11 + GIT_SHALLOW TRUE + ) + set(DOCTEST_WITH_TESTS OFF CACHE INTERNAL "") + set(DOCTEST_WITH_MAIN_IN_STATIC_LIB OFF CACHE INTERNAL "") + FetchContent_MakeAvailable(doctest) +endif() + +if(CCM_USE_SYSTEM_WX) + message(STATUS "[ccm] Using system wxWidgets via find_package") + find_package(wxWidgets REQUIRED COMPONENTS core base) + include(${wxWidgets_USE_FILE}) + # Re-export as a cleaner imported target so the rest of the build can + # depend on `wx::wx` regardless of how it was provided. + add_library(wx::wx INTERFACE IMPORTED) + target_include_directories(wx::wx INTERFACE ${wxWidgets_INCLUDE_DIRS}) + target_compile_definitions(wx::wx INTERFACE ${wxWidgets_DEFINITIONS}) + target_link_libraries(wx::wx INTERFACE ${wxWidgets_LIBRARIES}) +else() + message(STATUS "[ccm] Fetching wxWidgets (slow on first configure) ...") + set(wxBUILD_SHARED OFF CACHE INTERNAL "") + set(wxBUILD_PRECOMP OFF CACHE INTERNAL "") + set(wxBUILD_INSTALL OFF CACHE INTERNAL "") + set(wxUSE_STL ON CACHE INTERNAL "") + set(wxUSE_GUI ON CACHE INTERNAL "") + FetchContent_Declare( + wxWidgets + GIT_REPOSITORY https://github.com/wxWidgets/wxWidgets.git + GIT_TAG v3.2.5 + GIT_SHALLOW TRUE + ) + FetchContent_MakeAvailable(wxWidgets) + # wxWidgets provides `wx::core`, `wx::base` etc. as targets when used as + # a sub-project. Aggregate the ones we need into `wx::wx`. + if(NOT TARGET wx::wx) + add_library(wx::wx INTERFACE IMPORTED) + target_link_libraries(wx::wx INTERFACE wx::core wx::base) + endif() +endif() diff --git a/cmake/Toolchain.cmake b/cmake/Toolchain.cmake new file mode 100644 index 0000000..5a7721d --- /dev/null +++ b/cmake/Toolchain.cmake @@ -0,0 +1,49 @@ +# Toolchain detection. +# +# Preferred compiler is Clang. On Windows hosts we additionally fall back to +# MinGW-w64 g++ when Clang is not present. MSVC is intentionally not supported. +# +# Run this BEFORE project() so the resulting CMAKE_C(XX)_COMPILER takes effect. + +if(DEFINED CMAKE_CXX_COMPILER) + # Respect explicit user override. + return() +endif() + +# Look for clang / clang++ first. +find_program(_CCM_CLANG NAMES clang clang-cl) +find_program(_CCM_CLANGXX NAMES clang++ clang-cl) + +if(_CCM_CLANG AND _CCM_CLANGXX) + set(CMAKE_C_COMPILER "${_CCM_CLANG}" CACHE FILEPATH "C compiler" FORCE) + set(CMAKE_CXX_COMPILER "${_CCM_CLANGXX}" CACHE FILEPATH "C++ compiler" FORCE) + message(STATUS "[ccm] Toolchain: Clang at ${_CCM_CLANGXX}") + return() +endif() + +if(WIN32) + # MinGW-w64 GCC fallback. + find_program(_CCM_GCC NAMES x86_64-w64-mingw32-gcc gcc) + find_program(_CCM_GXX NAMES x86_64-w64-mingw32-g++ g++) + if(_CCM_GCC AND _CCM_GXX) + set(CMAKE_C_COMPILER "${_CCM_GCC}" CACHE FILEPATH "C compiler" FORCE) + set(CMAKE_CXX_COMPILER "${_CCM_GXX}" CACHE FILEPATH "C++ compiler" FORCE) + message(STATUS "[ccm] Toolchain: MinGW-w64 GCC at ${_CCM_GXX}") + return() + endif() + message(WARNING + "[ccm] Neither Clang nor MinGW-w64 GCC was found in PATH. " + "Install LLVM (preferred) or MSYS2's mingw-w64-x86_64-gcc and re-run." + ) +else() + # Unix-likes: GCC fallback. + find_program(_CCM_GCC NAMES gcc cc) + find_program(_CCM_GXX NAMES g++ c++) + if(_CCM_GCC AND _CCM_GXX) + set(CMAKE_C_COMPILER "${_CCM_GCC}" CACHE FILEPATH "C compiler" FORCE) + set(CMAKE_CXX_COMPILER "${_CCM_GXX}" CACHE FILEPATH "C++ compiler" FORCE) + message(STATUS "[ccm] Toolchain: GCC at ${_CCM_GXX}") + return() + endif() + message(WARNING "[ccm] Neither Clang nor GCC was found in PATH.") +endif() diff --git a/core/AGENTS.md b/core/AGENTS.md new file mode 100644 index 0000000..2b126b4 --- /dev/null +++ b/core/AGENTS.md @@ -0,0 +1,59 @@ +# core/AGENTS.md + +`ccm_core` static library — domain types, ports, services, infra adapters. Hard rule: **no UI dependencies, ever**. Read the root `AGENTS.md` first. + +## Layer pointers + +- `include/ccm/domain/` — POD value types: `Enums`, `Set`, `MagicCard`, `PokemonCard`, `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`. +- `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`). All seams the services depend on. Add new ports here when adding new external concerns. +- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list). They depend only on ports. +- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository` (header-only template), `JsonSetRepository`, `LocalImageStore`. +- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/` and `pokemon/` are the reference implementations — both expose a fully working set source + card preview source. +- `include/ccm/util/` — `Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`). +- `src/` mirrors `include/ccm/` for non-template implementations. + +## Conventions + +1. **No throw across ports.** Return `ccm::Result::ok(...)` / `Result::err("msg")`. The caller propagates with `if (!r) return Result::err(r.error());`. +2. **JSON serde stays byte-for-byte stable.** When the C++ field name differs from the JSON key (`signed_` vs `"signed"`, `releaseDate`, `setNo`, `firstEdition`, `dataStorage`, `defaultGame`), write hand-rolled `to_json` / `from_json` instead of `NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE` so the alias is explicit. Round-trip tests in `tests/domain_json_tests.cpp` enforce this — extend them whenever you touch a domain type. +3. **Filename rule for images** lives in `services/ImageService.hpp` and matches the Rust source exactly: + - new entry -> `"{set}+{name}+{idx}.{ext}"` + - existing -> `"{id}+{set}+{name}+{idx}.{ext}"` + `ImageService::buildTargetName` is the single source of truth. Don't duplicate the rule elsewhere. +4. **Templates stay header-only** (`CollectionService`, `JsonCollectionRepository`). Don't add `.cpp` files for them; explicit instantiation is not used. +5. **Path strings** that get persisted (e.g. `Configuration::dataStorage`) use `std::filesystem::path::generic_string()`, never `string()` — keeps `/` separators on Windows so JSON round-trips and tests stay portable. +6. **Compiler warnings**: every target in this package links `ccm_warnings` `PRIVATE`. Treat warnings as errors locally during dev (`-Werror` is opt-in but encouraged). +7. **No `wx/...` includes** in headers or sources here. Verify with `rg "wx/" core/` — must be empty. +8. **HTTP query strings must be percent-encoded** before they reach `IHttpClient::get`. `cpr::Url` does **not** encode the URL string we hand it. See `MagicCardPreviewSource::buildSearchUrl` for the canonical pattern (RFC 3986 unreserved-set encoder). `IHttpClient::get` accepts arbitrary bytes back — `Result` is a binary buffer, not text, so callers can use it for image payloads directly. + +## Adding a new game + +The end-to-end procedure (core + UI + composition root + docs) lives in `docs/adding-a-new-game.md`. The core-side checklist is: + +1. Add `Game::` plus `to_string` / `FromString` / `allGames()` entries in `include/ccm/domain/Enums.hpp` and `src/domain/Enums.cpp`. +2. Create `include/ccm/games//SetSource.hpp` + `.cpp` implementing `ISetSource`. Mirror `MagicSetSource` / `PokemonSetSource`: expose a static `parseResponse(std::string)` helper so it's unit-testable without HTTP. +3. (Optional) Create `include/ccm/games//CardPreviewSource.hpp` + `.cpp` implementing `ICardPreviewSource`. Mirror `MagicCardPreviewSource` / `PokemonCardPreviewSource`: expose static `buildSearchUrl` + `parseResponse` helpers for unit testing without HTTP. +4. Create `include/ccm/games//GameModule.hpp` + `.cpp` implementing `IGameModule`. Pick a stable lowercase `dirName()` — it becomes the on-disk subdirectory and must never change. The module **owns** its set source and (optionally) its card preview source: override `cardPreviewSource()` to return `&previewSource_` when present (default returns `nullptr`). +5. If the game has a card type with different fields, add a `Card` domain type with hand-rolled JSON aliases. Otherwise reuse an existing one. +6. Add the new `.cpp` files to `core/CMakeLists.txt` (no glob). +7. Add tests under `tests/_set_source_tests.cpp` and `tests/_card_preview_source_tests.cpp` modeled on the Magic / Pokemon versions. +8. The composition root in `app/main.cpp` and the directory mapping in `app/main.cpp::dirNameForGame` must be updated too — see `app/AGENTS.md`. `CardPreviewService::registerModule(*module)` is the single registration call; modules whose `cardPreviewSource()` returns `nullptr` are silently skipped. + +## Adding / changing a card-table column + +When you add or rename a `tableFields` entry on a list panel (Magic or Pokemon), keep `core/`'s sort/filter helpers and their tests in lockstep: + +1. Extend `MagicSortColumn` / `PokemonSortColumn` and add a `case` branch in `sortMagicCards` / `sortPokemonCards` (`core/include/ccm/services/CardSorter.hpp` + `.cpp`). +2. Add the new value-key column to the matching `matchesMagicFilter` / `matchesPokemonFilter` (`core/include/ccm/services/CardFilter.hpp` + `.cpp`) — boolean-flag columns are *excluded* (the filtering rule only checks values equivalent to JS `typeof === "string" | "number"`). +3. Add tests under `tests/card_sorter_tests.cpp` and `tests/card_filter_tests.cpp`. + +## Adding a new port + +1. Add the interface header under `include/ccm/ports/` with `virtual ~IFoo() = default;`. +2. Implement the adapter under `include/ccm/infra/` + `src/infra/`. Mark it `final`. +3. Update `core/CMakeLists.txt`. Wire it into the relevant service's constructor. +4. Add a fake under `tests/fakes/` modeled on `InMemoryFileSystem` and write service-level tests against it. + +## Commands + +Build core only: `cmake --build build --target ccm_core` diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt new file mode 100644 index 0000000..3fa3ecf --- /dev/null +++ b/core/CMakeLists.txt @@ -0,0 +1,49 @@ +# ccm_core: UI-agnostic domain, services, ports, and infra adapters. +# This target MUST NOT depend on wxWidgets or any UI toolkit. + +add_library(ccm_core STATIC + src/domain/Enums.cpp + src/domain/Set.cpp + src/domain/MagicCard.cpp + src/domain/PokemonCard.cpp + src/domain/Configuration.cpp + + src/services/ConfigService.cpp + src/services/ImageService.cpp + src/services/SetService.cpp + src/services/CardPreviewService.cpp + src/services/CardSorter.cpp + src/services/CardFilter.cpp + + src/infra/CprHttpClient.cpp + src/infra/StdFileSystem.cpp + src/infra/JsonSetRepository.cpp + src/infra/LocalImageStore.cpp + + src/games/magic/MagicSetSource.cpp + src/games/magic/MagicCardPreviewSource.cpp + src/games/magic/MagicGameModule.cpp + src/games/pokemon/PokemonSetSource.cpp + src/games/pokemon/PokemonCardPreviewSource.cpp + src/games/pokemon/PokemonGameModule.cpp + + src/util/FsNames.cpp +) + +target_include_directories(ccm_core + PUBLIC + $ +) + +target_link_libraries(ccm_core + PUBLIC + nlohmann_json::nlohmann_json + PRIVATE + cpr::cpr + ccm_warnings +) + +target_compile_features(ccm_core PUBLIC cxx_std_20) + +# JsonCollectionRepository and CollectionService are header-only templates +# and live entirely under include/ccm/ - nothing to compile here for them. diff --git a/core/include/ccm/domain/Configuration.hpp b/core/include/ccm/domain/Configuration.hpp new file mode 100644 index 0000000..f3d2c0e --- /dev/null +++ b/core/include/ccm/domain/Configuration.hpp @@ -0,0 +1,26 @@ +#pragma once + +// Configuration: matches the persisted `config.json` schema used by this app. +// +// { "dataStorage": "/abs/path", "defaultGame": "Magic" } + +#include "ccm/domain/Enums.hpp" + +#include + +#include + +namespace ccm { + +struct Configuration { + std::string dataStorage; + Game defaultGame{Game::Magic}; + Theme theme{Theme::Light}; + + friend bool operator==(const Configuration&, const Configuration&) = default; +}; + +void to_json(nlohmann::json& j, const Configuration& c); +void from_json(const nlohmann::json& j, Configuration& c); + +} // namespace ccm diff --git a/core/include/ccm/domain/Enums.hpp b/core/include/ccm/domain/Enums.hpp new file mode 100644 index 0000000..acdfc8f --- /dev/null +++ b/core/include/ccm/domain/Enums.hpp @@ -0,0 +1,77 @@ +#pragma once + +// Game / Language / Condition enums. +// +// String spellings are kept identical to the established Rust serde defaults +// (e.g. `NearMint`, `LightPlayed`, `Magic`, `Pokemon`) so existing JSON files +// remain interchangeable. + +#include + +#include +#include +#include +#include + +namespace ccm { + +enum class Game { + Magic, + Pokemon, +}; + +enum class Language { + English, + German, + French, + Spanish, + Italian, + Chinese, + Japanese, + Russian, +}; + +enum class Condition { + Mint, + NearMint, + Excellent, + Good, + LightPlayed, + Played, + Poor, +}; + +enum class Theme { + Light, + Dark, +}; + +std::string_view to_string(Game g) noexcept; +std::string_view to_string(Language l) noexcept; +std::string_view to_string(Condition c) noexcept; +std::string_view to_string(Theme t) noexcept; + +std::optional gameFromString(std::string_view s) noexcept; +std::optional languageFromString(std::string_view s) noexcept; +std::optional conditionFromString(std::string_view s) noexcept; +std::optional themeFromString(std::string_view s) noexcept; + +const std::array& allGames() noexcept; +const std::array& allLanguages() noexcept; +const std::array& allConditions() noexcept; +const std::array& allThemes() noexcept; + +// nlohmann/json hooks - serialize as plain strings, matching Rust serde. +void to_json(nlohmann::json& j, Game v); +void from_json(const nlohmann::json& j, Game& v); + +void to_json(nlohmann::json& j, Language v); +void from_json(const nlohmann::json& j, Language& v); + +void to_json(nlohmann::json& j, Condition v); +void from_json(const nlohmann::json& j, Condition& v); + +void to_json(nlohmann::json& j, Theme v); +void from_json(const nlohmann::json& j, Theme& v); + +} // namespace ccm diff --git a/core/include/ccm/domain/MagicCard.hpp b/core/include/ccm/domain/MagicCard.hpp new file mode 100644 index 0000000..cfdd2de --- /dev/null +++ b/core/include/ccm/domain/MagicCard.hpp @@ -0,0 +1,36 @@ +#pragma once + +// MagicCard - faithful port of magic/card_services.rs::Card. +// JSON layout remains stable so collection.json files stay interchangeable. + +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/Set.hpp" + +#include + +#include +#include +#include + +namespace ccm { + +struct MagicCard { + std::uint32_t id{0}; + std::uint8_t amount{1}; + std::string name; + Set set; + std::string note; + std::vector images; + Language language{Language::English}; + Condition condition{Condition::NearMint}; + bool foil{false}; + bool signed_{false}; // `signed` is a reserved keyword + bool altered{false}; + + friend bool operator==(const MagicCard&, const MagicCard&) = default; +}; + +void to_json(nlohmann::json& j, const MagicCard& c); +void from_json(const nlohmann::json& j, MagicCard& c); + +} // namespace ccm diff --git a/core/include/ccm/domain/PokemonCard.hpp b/core/include/ccm/domain/PokemonCard.hpp new file mode 100644 index 0000000..92a1085 --- /dev/null +++ b/core/include/ccm/domain/PokemonCard.hpp @@ -0,0 +1,38 @@ +#pragma once + +// PokemonCard - faithful port of pokemon/card_services.rs::Card. +// Same established JSON shape (with `setNo` and `firstEdition` aliases). + +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/Set.hpp" + +#include + +#include +#include +#include + +namespace ccm { + +struct PokemonCard { + std::uint32_t id{0}; + std::uint8_t amount{1}; + std::string name; + Set set; + std::string setNo; + std::string note; + std::vector images; + Language language{Language::English}; + Condition condition{Condition::NearMint}; + bool firstEdition{false}; + bool holo{false}; + bool signed_{false}; + bool altered{false}; + + friend bool operator==(const PokemonCard&, const PokemonCard&) = default; +}; + +void to_json(nlohmann::json& j, const PokemonCard& c); +void from_json(const nlohmann::json& j, PokemonCard& c); + +} // namespace ccm diff --git a/core/include/ccm/domain/Set.hpp b/core/include/ccm/domain/Set.hpp new file mode 100644 index 0000000..138a558 --- /dev/null +++ b/core/include/ccm/domain/Set.hpp @@ -0,0 +1,24 @@ +#pragma once + +// Set: identifier of a printing run shared by both supported games. +// JSON shape matches the original Rust Set struct exactly: +// { "id": "...", "name": "...", "releaseDate": "YYYY/MM/DD" } + +#include + +#include + +namespace ccm { + +struct Set { + std::string id; + std::string name; + std::string releaseDate; // formatted as "YYYY/MM/DD" + + friend bool operator==(const Set&, const Set&) = default; +}; + +void to_json(nlohmann::json& j, const Set& s); +void from_json(const nlohmann::json& j, Set& s); + +} // namespace ccm diff --git a/core/include/ccm/games/IGameModule.hpp b/core/include/ccm/games/IGameModule.hpp new file mode 100644 index 0000000..cd8584a --- /dev/null +++ b/core/include/ccm/games/IGameModule.hpp @@ -0,0 +1,47 @@ +#pragma once + +// IGameModule + ISetSource: the seam that lets the UI handle Magic and Pokemon +// uniformly. New game support is "implement these interfaces and register the +// module in the composition root" - see the Rust `templates/` module for the +// pattern this is modeled after. + +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/Set.hpp" +#include "ccm/ports/ICardPreviewSource.hpp" +#include "ccm/util/Result.hpp" + +#include +#include + +namespace ccm { + +class ISetSource { +public: + virtual ~ISetSource() = default; + + // Fetch the canonical set list from the game's external API. + // Implementations return a vector that has already been filtered + // (e.g. no digital-only sets) and sorted by release date ascending. + virtual Result> fetchAll() = 0; +}; + +class IGameModule { +public: + virtual ~IGameModule() = default; + + [[nodiscard]] virtual Game id() const noexcept = 0; + // Subdirectory name used inside the data storage root. Matches the Rust + // string literals "magic" / "pokemon". + [[nodiscard]] virtual std::string dirName() const = 0; + [[nodiscard]] virtual std::string displayName() const = 0; + + virtual ISetSource& setSource() = 0; + + // Optional preview source. Returning nullptr signals the game has no + // remote preview API (the UI then renders only locally stored images). + // The default keeps existing modules compiling without forcing every + // game to provide one. + virtual ICardPreviewSource* cardPreviewSource() noexcept { return nullptr; } +}; + +} // namespace ccm diff --git a/core/include/ccm/games/magic/MagicCardPreviewSource.hpp b/core/include/ccm/games/magic/MagicCardPreviewSource.hpp new file mode 100644 index 0000000..2c3e5ab --- /dev/null +++ b/core/include/ccm/games/magic/MagicCardPreviewSource.hpp @@ -0,0 +1,42 @@ +#pragma once + +// MagicCardPreviewSource: ICardPreviewSource implementation for Magic the +// Gathering. Calls Scryfall's search endpoint at +// https://api.scryfall.com/cards/search?q=name:"" AND set: +// and returns `data[0].image_uris.normal`. Mirrors the established +// `src/components/magic/SelectedMtgPanel.tsx::getImage`, including the +// `&` -> `and` substitution in the card name. + +#include "ccm/ports/ICardPreviewSource.hpp" +#include "ccm/ports/IHttpClient.hpp" + +#include +#include + +namespace ccm { + +class MagicCardPreviewSource final : public ICardPreviewSource { +public: + explicit MagicCardPreviewSource(IHttpClient& http); + + Result fetchImageUrl(std::string_view name, + std::string_view setId, + std::string_view setNo) override; + + // Build the fully URL-encoded Scryfall search URL for the given card. + // Exposed for unit testing and to keep encoding rules in one place. + static std::string buildSearchUrl(std::string_view name, + std::string_view setId); + + // Parse a Scryfall /cards/search response body and pull out the + // `data[0].image_uris.normal` URL. Returns an error result when no + // matching printing is found, when the JSON is malformed, or when the + // entry has no top-level `image_uris` (double-faced cards expose them + // on a face object - no fallback in this compatibility behavior either). + static Result parseResponse(const std::string& body); + +private: + IHttpClient& http_; +}; + +} // namespace ccm diff --git a/core/include/ccm/games/magic/MagicGameModule.hpp b/core/include/ccm/games/magic/MagicGameModule.hpp new file mode 100644 index 0000000..9cc883c --- /dev/null +++ b/core/include/ccm/games/magic/MagicGameModule.hpp @@ -0,0 +1,29 @@ +#pragma once + +// MagicGameModule: IGameModule for Magic the Gathering. Owns its set source +// and card preview source, reports the canonical "magic" subdirectory name +// used on disk. + +#include "ccm/games/IGameModule.hpp" +#include "ccm/games/magic/MagicCardPreviewSource.hpp" +#include "ccm/games/magic/MagicSetSource.hpp" + +namespace ccm { + +class MagicGameModule final : public IGameModule { +public: + explicit MagicGameModule(IHttpClient& http); + + [[nodiscard]] Game id() const noexcept override { return Game::Magic; } + [[nodiscard]] std::string dirName() const override { return "magic"; } + [[nodiscard]] std::string displayName() const override { return "Magic"; } + + ISetSource& setSource() override { return setSource_; } + ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; } + +private: + MagicSetSource setSource_; + MagicCardPreviewSource previewSource_; +}; + +} // namespace ccm diff --git a/core/include/ccm/games/magic/MagicSetSource.hpp b/core/include/ccm/games/magic/MagicSetSource.hpp new file mode 100644 index 0000000..907364a --- /dev/null +++ b/core/include/ccm/games/magic/MagicSetSource.hpp @@ -0,0 +1,28 @@ +#pragma once + +// MagicSetSource: ISetSource implementation for Magic the Gathering. +// Calls the Scryfall API at https://api.scryfall.com/sets, drops digital-only +// sets, maps the response into our `Set` domain type, and sorts by release date +// ascending. Behavior matches `magic/set_services.rs::update_sets`. + +#include "ccm/games/IGameModule.hpp" +#include "ccm/ports/IHttpClient.hpp" + +namespace ccm { + +class MagicSetSource final : public ISetSource { +public: + static constexpr const char* kEndpoint = "https://api.scryfall.com/sets"; + + explicit MagicSetSource(IHttpClient& http); + + Result> fetchAll() override; + + // Pure parser exposed for unit testing without a network round-trip. + static Result> parseResponse(const std::string& body); + +private: + IHttpClient& http_; +}; + +} // namespace ccm diff --git a/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp b/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp new file mode 100644 index 0000000..39adb33 --- /dev/null +++ b/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp @@ -0,0 +1,42 @@ +#pragma once + +// PokemonCardPreviewSource: ICardPreviewSource implementation for the Pokemon +// TCG. Calls the Pokemon TCG search endpoint at +// https://api.pokemontcg.io/v2/cards?q=name:"" set.id: number: +// and returns `data[0].images.large` (with `images.small` as a graceful +// fallback). Mirrors the established `getImage` flow in +// `src/components/pokemon/SelectedPokemonPanel.tsx`. + +#include "ccm/ports/ICardPreviewSource.hpp" +#include "ccm/ports/IHttpClient.hpp" + +#include +#include + +namespace ccm { + +class PokemonCardPreviewSource final : public ICardPreviewSource { +public: + explicit PokemonCardPreviewSource(IHttpClient& http); + + Result fetchImageUrl(std::string_view name, + std::string_view setId, + std::string_view setNo) override; + + // Build the fully URL-encoded Pokemon TCG search URL for the given card. + // Exposed for unit testing and to keep encoding rules in one place. + static std::string buildSearchUrl(std::string_view name, + std::string_view setId, + std::string_view setNo); + + // Parse a Pokemon TCG /v2/cards response body and pull out the image URL + // for the first matching card. Prefers `images.large`, falls back to + // `images.small`, and returns an error result if neither is present, the + // data array is empty, or the JSON is malformed. + static Result parseResponse(const std::string& body); + +private: + IHttpClient& http_; +}; + +} // namespace ccm diff --git a/core/include/ccm/games/pokemon/PokemonGameModule.hpp b/core/include/ccm/games/pokemon/PokemonGameModule.hpp new file mode 100644 index 0000000..62d6114 --- /dev/null +++ b/core/include/ccm/games/pokemon/PokemonGameModule.hpp @@ -0,0 +1,28 @@ +#pragma once + +// PokemonGameModule: IGameModule for the Pokemon TCG. Owns its set source +// and card preview source, both backed by api.pokemontcg.io/v2. + +#include "ccm/games/IGameModule.hpp" +#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp" +#include "ccm/games/pokemon/PokemonSetSource.hpp" + +namespace ccm { + +class PokemonGameModule final : public IGameModule { +public: + explicit PokemonGameModule(IHttpClient& http); + + [[nodiscard]] Game id() const noexcept override { return Game::Pokemon; } + [[nodiscard]] std::string dirName() const override { return "pokemon"; } + [[nodiscard]] std::string displayName() const override { return "Pokemon"; } + + ISetSource& setSource() override { return setSource_; } + ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; } + +private: + PokemonSetSource setSource_; + PokemonCardPreviewSource previewSource_; +}; + +} // namespace ccm diff --git a/core/include/ccm/games/pokemon/PokemonSetSource.hpp b/core/include/ccm/games/pokemon/PokemonSetSource.hpp new file mode 100644 index 0000000..7222638 --- /dev/null +++ b/core/include/ccm/games/pokemon/PokemonSetSource.hpp @@ -0,0 +1,30 @@ +#pragma once + +// PokemonSetSource: ISetSource implementation for the Pokemon TCG. +// Calls the Pokemon TCG API at https://api.pokemontcg.io/v2/sets, maps the +// response into our `Set` domain type, and sorts by release date ascending. +// The Pokemon TCG API already returns `releaseDate` in `YYYY/MM/DD` format, +// so no rewriting is needed (unlike Scryfall's `released_at`). +// Behavior matches `pokemon/set_services.rs::update_sets`. + +#include "ccm/games/IGameModule.hpp" +#include "ccm/ports/IHttpClient.hpp" + +namespace ccm { + +class PokemonSetSource final : public ISetSource { +public: + static constexpr const char* kEndpoint = "https://api.pokemontcg.io/v2/sets"; + + explicit PokemonSetSource(IHttpClient& http); + + Result> fetchAll() override; + + // Pure parser exposed for unit testing without a network round-trip. + static Result> parseResponse(const std::string& body); + +private: + IHttpClient& http_; +}; + +} // namespace ccm diff --git a/core/include/ccm/infra/CprHttpClient.hpp b/core/include/ccm/infra/CprHttpClient.hpp new file mode 100644 index 0000000..e4c6b46 --- /dev/null +++ b/core/include/ccm/infra/CprHttpClient.hpp @@ -0,0 +1,23 @@ +#pragma once + +// CprHttpClient: cpr-based implementation of IHttpClient. +// All cpr/libcurl symbols stay confined to the .cpp file - any TU that just +// needs to make HTTP calls only depends on the IHttpClient port. + +#include "ccm/ports/IHttpClient.hpp" + +#include + +namespace ccm { + +class CprHttpClient final : public IHttpClient { +public: + explicit CprHttpClient(std::chrono::milliseconds timeout = std::chrono::milliseconds{30000}); + + Result get(std::string_view url) override; + +private: + std::chrono::milliseconds timeout_; +}; + +} // namespace ccm diff --git a/core/include/ccm/infra/JsonCollectionRepository.hpp b/core/include/ccm/infra/JsonCollectionRepository.hpp new file mode 100644 index 0000000..0c4ecaa --- /dev/null +++ b/core/include/ccm/infra/JsonCollectionRepository.hpp @@ -0,0 +1,85 @@ +#pragma once + +// JsonCollectionRepository: persists std::map as a JSON object +// keyed by stringified id. Layout matches the established collection file: +// +// //collection.json -> { "0": {...}, "1": {...} } +// +// Header-only because it's a template over the card type. + +#include "ccm/domain/Configuration.hpp" +#include "ccm/domain/Enums.hpp" +#include "ccm/games/IGameModule.hpp" +#include "ccm/ports/ICollectionRepository.hpp" +#include "ccm/ports/IFileSystem.hpp" +#include "ccm/services/ConfigService.hpp" +#include "ccm/util/Result.hpp" + +#include + +#include +#include +#include + +namespace ccm { + +template +class JsonCollectionRepository final : public ICollectionRepository { +public: + using Map = typename ICollectionRepository::Map; + // The repository needs a way to translate a Game enum into the per-game + // subdirectory name ("magic" / "pokemon"); a small lookup function keeps + // this layer free of concrete game module types. + using DirNameFn = std::function; + + JsonCollectionRepository(IFileSystem& fs, ConfigService& config, DirNameFn dirName) + : fs_(fs), config_(config), dirName_(std::move(dirName)) {} + + Result load(Game game) override { + const auto p = collectionPath(game); + if (!fs_.exists(p)) { + // Mirror the Rust "create on first read" semantics so the UI + // never sees a missing-file error on a fresh install. + Map empty; + auto saved = save(game, empty); + if (!saved) return Result::err(saved.error()); + return Result::ok(std::move(empty)); + } + auto text = fs_.readText(p); + if (!text) return Result::err(text.error()); + try { + auto j = nlohmann::json::parse(text.value()); + Map out; + for (auto it = j.begin(); it != j.end(); ++it) { + std::uint32_t key = static_cast(std::stoul(it.key())); + out.emplace(key, it.value().template get()); + } + return Result::ok(std::move(out)); + } catch (const std::exception& e) { + return Result::err(std::string("JSON parse error: ") + e.what()); + } + } + + Result save(Game game, const Map& collection) override { + nlohmann::json j = nlohmann::json::object(); + for (const auto& [id, card] : collection) { + j[std::to_string(id)] = card; + } + const auto p = collectionPath(game); + auto dirRes = fs_.ensureDirectory(p.parent_path()); + if (!dirRes) return dirRes; + return fs_.writeText(p, j.dump(2)); + } + +private: + std::filesystem::path collectionPath(Game game) const { + return std::filesystem::path(config_.current().dataStorage) / + dirName_(game) / "collection.json"; + } + + IFileSystem& fs_; + ConfigService& config_; + DirNameFn dirName_; +}; + +} // namespace ccm diff --git a/core/include/ccm/infra/JsonSetRepository.hpp b/core/include/ccm/infra/JsonSetRepository.hpp new file mode 100644 index 0000000..f462a50 --- /dev/null +++ b/core/include/ccm/infra/JsonSetRepository.hpp @@ -0,0 +1,32 @@ +#pragma once + +// JsonSetRepository: persists vector to `//sets.json`. + +#include "ccm/games/IGameModule.hpp" +#include "ccm/ports/IFileSystem.hpp" +#include "ccm/ports/ISetRepository.hpp" +#include "ccm/services/ConfigService.hpp" + +#include +#include + +namespace ccm { + +class JsonSetRepository final : public ISetRepository { +public: + using DirNameFn = std::function; + + JsonSetRepository(IFileSystem& fs, ConfigService& config, DirNameFn dirName); + + Result> load(Game game) override; + Result save(Game game, const std::vector& sets) override; + +private: + IFileSystem& fs_; + ConfigService& config_; + DirNameFn dirName_; + + [[nodiscard]] std::filesystem::path setsPath(Game game) const; +}; + +} // namespace ccm diff --git a/core/include/ccm/infra/LocalImageStore.hpp b/core/include/ccm/infra/LocalImageStore.hpp new file mode 100644 index 0000000..ce08bd2 --- /dev/null +++ b/core/include/ccm/infra/LocalImageStore.hpp @@ -0,0 +1,37 @@ +#pragma once + +// LocalImageStore: stores card images under +// `//images/`. +// Implements IImageStore, used by ImageService. + +#include "ccm/games/IGameModule.hpp" +#include "ccm/ports/IFileSystem.hpp" +#include "ccm/ports/IImageStore.hpp" +#include "ccm/services/ConfigService.hpp" + +#include +#include + +namespace ccm { + +class LocalImageStore final : public IImageStore { +public: + using DirNameFn = std::function; + + LocalImageStore(IFileSystem& fs, ConfigService& config, DirNameFn dirName); + + Result copyIn(Game game, + const std::filesystem::path& srcPath, + const std::string& targetName) override; + Result remove(Game game, const std::string& imageName) override; + std::filesystem::path resolvePath(Game game, const std::string& imageName) const override; + +private: + IFileSystem& fs_; + ConfigService& config_; + DirNameFn dirName_; + + [[nodiscard]] std::filesystem::path gameImageDir(Game game) const; +}; + +} // namespace ccm diff --git a/core/include/ccm/infra/StdFileSystem.hpp b/core/include/ccm/infra/StdFileSystem.hpp new file mode 100644 index 0000000..d84daa9 --- /dev/null +++ b/core/include/ccm/infra/StdFileSystem.hpp @@ -0,0 +1,25 @@ +#pragma once + +// std::filesystem-backed implementation of IFileSystem. + +#include "ccm/ports/IFileSystem.hpp" + +namespace ccm { + +class StdFileSystem final : public IFileSystem { +public: + [[nodiscard]] bool exists(const std::filesystem::path& p) const override; + [[nodiscard]] bool isDirectory(const std::filesystem::path& p) const override; + + Result ensureDirectory(const std::filesystem::path& p) override; + Result readText(const std::filesystem::path& p) override; + Result writeText(const std::filesystem::path& p, std::string_view contents) override; + Result copyFile(const std::filesystem::path& from, + const std::filesystem::path& to, + bool overwrite) override; + Result remove(const std::filesystem::path& p) override; + Result> listDirectory( + const std::filesystem::path& p) override; +}; + +} // namespace ccm diff --git a/core/include/ccm/ports/ICardPreviewSource.hpp b/core/include/ccm/ports/ICardPreviewSource.hpp new file mode 100644 index 0000000..43a3046 --- /dev/null +++ b/core/include/ccm/ports/ICardPreviewSource.hpp @@ -0,0 +1,30 @@ +#pragma once + +// ICardPreviewSource - resolves the preview image URL for a single card via +// the active game's external API. Implementations stay HTTP-bound, no UI deps. +// +// Modeled after per-game `getImage` helpers in +// `src/components/{magic,pokemon}/Selected*Panel.tsx`. The resulting URL is +// then fetched as raw image bytes by `CardPreviewService` and decoded by the +// UI layer (wxImage in our wx adapter). + +#include "ccm/util/Result.hpp" + +#include +#include + +namespace ccm { + +class ICardPreviewSource { +public: + virtual ~ICardPreviewSource() = default; + + // Resolve the preview image URL for a single card. `setNo` is optional + // (empty string is fine); some game APIs (e.g. Pokemon TCG) can use it as + // a more precise lookup key, others (Magic/Scryfall) ignore it. + virtual Result fetchImageUrl(std::string_view name, + std::string_view setId, + std::string_view setNo) = 0; +}; + +} // namespace ccm diff --git a/core/include/ccm/ports/ICollectionRepository.hpp b/core/include/ccm/ports/ICollectionRepository.hpp new file mode 100644 index 0000000..21e4971 --- /dev/null +++ b/core/include/ccm/ports/ICollectionRepository.hpp @@ -0,0 +1,25 @@ +#pragma once + +// ICollectionRepository - persistence port for a per-game card collection, +// keyed by uint32_t id. Mirrors the HashMap in the original Rust code. + +#include "ccm/domain/Enums.hpp" +#include "ccm/util/Result.hpp" + +#include +#include + +namespace ccm { + +template +class ICollectionRepository { +public: + using Map = std::map; + + virtual ~ICollectionRepository() = default; + + virtual Result load(Game game) = 0; + virtual Result save(Game game, const Map& collection) = 0; +}; + +} // namespace ccm diff --git a/core/include/ccm/ports/IFileSystem.hpp b/core/include/ccm/ports/IFileSystem.hpp new file mode 100644 index 0000000..174990a --- /dev/null +++ b/core/include/ccm/ports/IFileSystem.hpp @@ -0,0 +1,33 @@ +#pragma once + +// IFileSystem - filesystem operations the services need, expressed as a +// narrow port. Real implementation is `StdFileSystem` (over ). +// In-memory implementation can be plugged in for tests. + +#include "ccm/util/Result.hpp" + +#include +#include +#include + +namespace ccm { + +class IFileSystem { +public: + virtual ~IFileSystem() = default; + + [[nodiscard]] virtual bool exists(const std::filesystem::path& p) const = 0; + [[nodiscard]] virtual bool isDirectory(const std::filesystem::path& p) const = 0; + + virtual Result ensureDirectory(const std::filesystem::path& p) = 0; + virtual Result readText(const std::filesystem::path& p) = 0; + virtual Result writeText(const std::filesystem::path& p, std::string_view contents) = 0; + virtual Result copyFile(const std::filesystem::path& from, + const std::filesystem::path& to, + bool overwrite) = 0; + virtual Result remove(const std::filesystem::path& p) = 0; + virtual Result> listDirectory( + const std::filesystem::path& p) = 0; +}; + +} // namespace ccm diff --git a/core/include/ccm/ports/IHttpClient.hpp b/core/include/ccm/ports/IHttpClient.hpp new file mode 100644 index 0000000..589619a --- /dev/null +++ b/core/include/ccm/ports/IHttpClient.hpp @@ -0,0 +1,26 @@ +#pragma once + +// IHttpClient - tiny abstraction over HTTP GET so the rest of the codebase +// never sees libcurl/cpr directly. Tests can substitute a fake client. + +#include "ccm/util/Result.hpp" + +#include +#include + +namespace ccm { + +class IHttpClient { +public: + virtual ~IHttpClient() = default; + + // Issue a blocking HTTPS GET. Returns the response body on success, an + // error string on failure (no exceptions across the port boundary). + // + // The returned `std::string` is a raw byte buffer - it is NOT decoded as + // text. Binary payloads (e.g. PNG/JPEG image bytes for the card preview + // feature) round-trip through this method intact. + virtual Result get(std::string_view url) = 0; +}; + +} // namespace ccm diff --git a/core/include/ccm/ports/IImageStore.hpp b/core/include/ccm/ports/IImageStore.hpp new file mode 100644 index 0000000..a311e9e --- /dev/null +++ b/core/include/ccm/ports/IImageStore.hpp @@ -0,0 +1,34 @@ +#pragma once + +// IImageStore - manages card image files on disk inside the per-game +// `images/` subdirectory. Returns absolute paths so the UI can decode with +// whichever image library it likes (we use wxImage in the wx adapter); core +// stays free of any image decoding dependency. + +#include "ccm/domain/Enums.hpp" +#include "ccm/util/Result.hpp" + +#include +#include + +namespace ccm { + +class IImageStore { +public: + virtual ~IImageStore() = default; + + // Copy `srcPath` into the per-game image dir under filename `targetName` + // (extension preserved from `srcPath`). Returns the final filename + // (basename only, no path) on success. + virtual Result copyIn(Game game, + const std::filesystem::path& srcPath, + const std::string& targetName) = 0; + + // Delete `imageName` from the per-game image dir. + virtual Result remove(Game game, const std::string& imageName) = 0; + + // Resolve `imageName` to an absolute path inside the per-game image dir. + virtual std::filesystem::path resolvePath(Game game, const std::string& imageName) const = 0; +}; + +} // namespace ccm diff --git a/core/include/ccm/ports/ISetRepository.hpp b/core/include/ccm/ports/ISetRepository.hpp new file mode 100644 index 0000000..95d348f --- /dev/null +++ b/core/include/ccm/ports/ISetRepository.hpp @@ -0,0 +1,22 @@ +#pragma once + +// ISetRepository - persistence port for the cached `sets.json` of a game. +// Stored as a flat list to mirror the original Rust file layout. + +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/Set.hpp" +#include "ccm/util/Result.hpp" + +#include + +namespace ccm { + +class ISetRepository { +public: + virtual ~ISetRepository() = default; + + virtual Result> load(Game game) = 0; + virtual Result save(Game game, const std::vector& sets) = 0; +}; + +} // namespace ccm diff --git a/core/include/ccm/services/CardFilter.hpp b/core/include/ccm/services/CardFilter.hpp new file mode 100644 index 0000000..ecf3399 --- /dev/null +++ b/core/include/ccm/services/CardFilter.hpp @@ -0,0 +1,41 @@ +#pragma once + +// CardFilter - row matcher aligned with TableTemplate.tsx::applyFilter semantics. +// kept a single `filter` string in the table component and, before rendering, +// kept only the rows where *any* of the `tableFields` value-key columns +// (string- or number-typed) contained the filter as a substring. Boolean flag +// columns (foil / signed / altered / holo / firstEdition) were skipped because +// `typeof` was neither "string" nor "number". +// +// We replicate that logic here as free functions so the UI can stay dumb (it +// just owns a wxTextCtrl and re-asks core which rows match). +// +// Differences worth knowing: +// * The match is case-insensitive on **both** sides — the old JS path only lowercased +// the cell value, leaving the filter as-typed, which made uppercase input +// never match. Lowercasing the filter too is the obvious-intent fix and +// keeps round-trip semantics for any filter the original UI would accept +// (lowercase filters behave identically). +// * An empty filter matches every row, exactly as in JS where every string +// `.includes("")` returns true. + +#include "ccm/domain/MagicCard.hpp" +#include "ccm/domain/PokemonCard.hpp" + +#include + +namespace ccm { + +// Magic value-key columns from the MtgTable.tsx tableFields list: +// name, set.name, language, condition, amount, note. +// Foil/Signed/Altered are bool-typed and intentionally excluded. +[[nodiscard]] bool matchesMagicFilter(const MagicCard& card, + std::string_view filter); + +// Pokemon value-key columns from PokemonTable.tsx tableFields list: +// name, set.name, setNo, language, condition, amount, note. +// Holo/FirstEdition/Signed/Altered are bool-typed and excluded. +[[nodiscard]] bool matchesPokemonFilter(const PokemonCard& card, + std::string_view filter); + +} // namespace ccm diff --git a/core/include/ccm/services/CardPreviewService.hpp b/core/include/ccm/services/CardPreviewService.hpp new file mode 100644 index 0000000..a9b62bf --- /dev/null +++ b/core/include/ccm/services/CardPreviewService.hpp @@ -0,0 +1,55 @@ +#pragma once + +// CardPreviewService: high-level operation for resolving and downloading a +// card's preview image from the active game's external API. Mirrors the +// `EntryPanelTemplate.tsx::useEffect([entry])` flow: +// 1. Per-game `ICardPreviewSource` resolves the card -> an image URL. +// 2. Service issues a GET against that URL through `IHttpClient`. +// 3. Raw bytes are returned to the caller (UI decodes them with whichever +// image lib it prefers - we use wxImage in the wx adapter). +// +// Sources are registered through `IGameModule::cardPreviewSource()`. Modules +// that return nullptr are silently skipped; games without a registered source +// produce an explicit error result on lookup rather than silently doing nothing. + +#include "ccm/domain/Enums.hpp" +#include "ccm/games/IGameModule.hpp" +#include "ccm/ports/ICardPreviewSource.hpp" +#include "ccm/ports/IHttpClient.hpp" +#include "ccm/util/Result.hpp" + +#include +#include +#include + +namespace ccm { + +class CardPreviewService { +public: + explicit CardPreviewService(IHttpClient& http); + + // Register a game module's preview source. Calling this with a module + // whose `cardPreviewSource()` returns nullptr is a no-op (the game has + // no remote preview API). The module reference must remain valid for + // the lifetime of the service. + void registerModule(IGameModule& module); + + // Resolve and download the preview image bytes for a single card. + // The returned `std::string` is a raw byte buffer (PNG/JPEG payload) - + // it is NOT decoded text. Use std::string::data()/size() with whatever + // image-decoding facility your UI provides. + Result fetchPreviewBytes(Game game, + std::string_view name, + std::string_view setId, + std::string_view setNo); + + // Download image bytes from a fully-qualified URL without going through + // per-game preview-source resolution. + Result fetchImageBytesByUrl(std::string_view url); + +private: + IHttpClient& http_; + std::unordered_map sources_; +}; + +} // namespace ccm diff --git a/core/include/ccm/services/CardSorter.hpp b/core/include/ccm/services/CardSorter.hpp new file mode 100644 index 0000000..78c3546 --- /dev/null +++ b/core/include/ccm/services/CardSorter.hpp @@ -0,0 +1,62 @@ +#pragma once + +// CardSorter - per-column sorting behavior matching the established table model +// `tableFields` configuration. The original TS code stored each column's +// (valueKey, sortKey) pair on a config object so that, e.g., the "Set" column +// could *display* `set.name` while sorting by `set.releaseDate`. We replicate +// that mapping here as two enums (one per supported game), each variant naming +// a sort key. The free functions below run a stable sort over a vector using +// the same per-type rules as the original `byField`: +// +// * strings -> case-insensitive (lowercased) `<` / `>` +// * numbers -> direct `<` / `>` +// * booleans -> direct `<` / `>` (so false < true; unset flags first when asc) +// +// Stable sort matches the JS `Array.prototype.sort` guarantee from ES2019; the +// UI relies on it so successive clicks on different columns compose predictably +// (e.g. sort by name, then by set => grouped by set, name-sorted within each). + +#include "ccm/domain/MagicCard.hpp" +#include "ccm/domain/PokemonCard.hpp" + +#include + +namespace ccm { + +// One value per *real* (non-spacer) column of the Magic table, in the same +// order as the Magic tableFields list. +enum class MagicSortColumn { + Name, + SetReleaseDate, // value column shows set.name, sort key is set.releaseDate + Language, + Condition, + Amount, + Foil, + Signed, + Altered, + Note, +}; + +// Pokemon equivalent (PokemonTable.tsx tableFields). Adds Holo + FirstEdition, +// drops Foil. +enum class PokemonSortColumn { + Name, + SetReleaseDate, + Language, + Condition, + Amount, + Holo, + FirstEdition, + Signed, + Altered, + Note, +}; + +// Stable in-place sort. `ascending=false` runs the same comparator with +// inverted sign, matching `byField(field, asc)` semantics. +void sortMagicCards(std::vector& cards, MagicSortColumn column, + bool ascending); +void sortPokemonCards(std::vector& cards, PokemonSortColumn column, + bool ascending); + +} // namespace ccm diff --git a/core/include/ccm/services/CollectionService.hpp b/core/include/ccm/services/CollectionService.hpp new file mode 100644 index 0000000..17151d5 --- /dev/null +++ b/core/include/ccm/services/CollectionService.hpp @@ -0,0 +1,126 @@ +#pragma once + +// CollectionService: high-level CRUD over the per-game collection. +// Header-only template that operates on the ICollectionRepository port and +// delegates image cleanup to an IImageStore on remove. +// +// This is the C++ equivalent of the Rust generic helpers in +// `templates/card_service_templates.rs` (add_entry_to_collection, +// update_entry_in_collection, get_entry_by_id, delete_entry_by_id, ...). +// +// Each TCard must expose `std::uint32_t id` and `std::vector images`. + +#include "ccm/domain/Enums.hpp" +#include "ccm/ports/ICollectionRepository.hpp" +#include "ccm/ports/IImageStore.hpp" +#include "ccm/util/Result.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ccm { + +template +class CollectionService { +public: + using Map = std::map; + + CollectionService(ICollectionRepository& repo, IImageStore& imageStore) + : repo_(repo), imageStore_(imageStore) {} + + Result> list(Game game) { + auto loaded = repo_.load(game); + if (!loaded) return Result>::err(loaded.error()); + Map map = std::move(loaded).value(); + std::vector out; + out.reserve(map.size()); + for (auto& [id, card] : map) { + (void)id; + out.push_back(std::move(card)); + } + return Result>::ok(std::move(out)); + } + + [[nodiscard]] static std::uint32_t nextId(const Map& map) noexcept { + if (map.empty()) return 0; + return map.rbegin()->first + 1; + } + + // Add a card. The `id` field on the input is overwritten with the next + // free id (matching the Rust HashMap-keyed behavior). + Result add(Game game, TCard card) { + auto loaded = repo_.load(game); + if (!loaded) return Result::err(loaded.error()); + Map map = std::move(loaded).value(); + const std::uint32_t newId = nextId(map); + card.id = newId; + map.emplace(newId, std::move(card)); + auto saved = repo_.save(game, map); + if (!saved) return Result::err(saved.error()); + return Result::ok(newId); + } + + // Update an existing entry, identified by `card.id`. If the id is not + // present, an error is returned. + Result update(Game game, TCard card) { + auto loaded = repo_.load(game); + if (!loaded) return Result::err(loaded.error()); + Map map = std::move(loaded).value(); + auto it = map.find(card.id); + if (it == map.end()) { + return Result::err("Card with id " + std::to_string(card.id) + + " not found in collection."); + } + it->second = std::move(card); + return repo_.save(game, map); + } + + // Remove the card with the given id. Also deletes any associated images + // via the IImageStore (best-effort - image removal failures are logged in + // the error string but the card itself is still purged from the JSON). + Result remove(Game game, std::uint32_t id) { + auto loaded = repo_.load(game); + if (!loaded) return Result::err(loaded.error()); + Map map = std::move(loaded).value(); + auto it = map.find(id); + if (it == map.end()) { + return Result::err("Card with id " + std::to_string(id) + " not found."); + } + std::string imgErr; + for (const auto& image : it->second.images) { + auto rm = imageStore_.remove(game, image); + if (!rm) { + if (!imgErr.empty()) imgErr += "; "; + imgErr += rm.error(); + } + } + map.erase(it); + auto saved = repo_.save(game, map); + if (!saved) return saved; + if (!imgErr.empty()) { + return Result::err("Card removed but image cleanup had issues: " + imgErr); + } + return Result::ok(); + } + + // Look up a card by id without mutating storage. + Result> findById(Game game, std::uint32_t id) { + auto loaded = repo_.load(game); + if (!loaded) return Result>::err(loaded.error()); + const auto& m = loaded.value(); + auto it = m.find(id); + if (it == m.end()) return Result>::ok(std::nullopt); + return Result>::ok(it->second); + } + +private: + ICollectionRepository& repo_; + IImageStore& imageStore_; +}; + +} // namespace ccm diff --git a/core/include/ccm/services/ConfigService.hpp b/core/include/ccm/services/ConfigService.hpp new file mode 100644 index 0000000..ff0d279 --- /dev/null +++ b/core/include/ccm/services/ConfigService.hpp @@ -0,0 +1,41 @@ +#pragma once + +// ConfigService: owns the live `Configuration`, persists it to `config.json` +// next to the executable. Mirrors `util/config.rs` behavior - a missing file +// is auto-created with sensible defaults on first launch. + +#include "ccm/domain/Configuration.hpp" +#include "ccm/ports/IFileSystem.hpp" +#include "ccm/util/Result.hpp" + +#include + +namespace ccm { + +class ConfigService { +public: + // `configFilePath` is the absolute path to `config.json`. The defaults + // applied to a freshly created config use `defaultDataStorage` for the + // dataStorage field. + ConfigService(IFileSystem& fs, + std::filesystem::path configFilePath, + std::filesystem::path defaultDataStorage); + + // Load (or create) the configuration. Must be called once at startup. + Result initialize(); + + [[nodiscard]] const Configuration& current() const noexcept { return current_; } + + // Replace the live configuration and persist immediately. + Result store(Configuration cfg); + +private: + IFileSystem& fs_; + std::filesystem::path path_; + std::filesystem::path defaultDataStorage_; + Configuration current_{}; + + [[nodiscard]] Configuration makeDefault() const; +}; + +} // namespace ccm diff --git a/core/include/ccm/services/ImageService.hpp b/core/include/ccm/services/ImageService.hpp new file mode 100644 index 0000000..1b0e999 --- /dev/null +++ b/core/include/ccm/services/ImageService.hpp @@ -0,0 +1,70 @@ +#pragma once + +// ImageService: applies the established image filename rules and delegates the actual +// disk operations to an IImageStore. +// +// Filename rule (mirrors Rust `magic/card_services.rs` and +// `pokemon/card_services.rs`): +// - new entry -> "{set}+{name}+{idx}.{ext}" +// - existing -> "{id}+{set}+{name}+{idx}.{ext}" +// +// The "+name+set+name+" convention is preserved byte-for-byte so legacy +// collections continue to display correctly when imported. + +#include "ccm/domain/Enums.hpp" +#include "ccm/ports/IImageStore.hpp" +#include "ccm/util/Result.hpp" + +#include +#include +#include +#include + +namespace ccm { + +class ImageService { +public: + explicit ImageService(IImageStore& store); + + // Compute the next image index from the previously stored image filenames + // for a card. Implements the same logic as the Rust card_services modules. + static std::uint8_t nextImageIndex(const std::vector& existingImages); + + // Build the target filename (without extension) for a new image attached + // to a card. Pass the card's existing id (0 if not yet stored) and a + // `newEntry` flag matching the established semantic. + static std::string buildTargetName(bool newEntry, + std::uint32_t cardId, + const std::string& setName, + const std::string& cardName, + std::uint8_t index); + + // Convenience: build target name + delegate copy to the IImageStore. + Result addImage(Game game, + const std::filesystem::path& srcPath, + bool newEntry, + std::uint32_t cardId, + const std::string& setName, + const std::string& cardName, + const std::vector& existingImages); + + Result removeImage(Game game, const std::string& imageName); + + // Ensure image filenames for a persisted card include the card id prefix. + // This upgrades "create-mode" names (`set+name+idx.ext`) to + // `id+set+name+idx.ext` to match the ccm2-compatible naming scheme. + Result> normalizeNamesForPersistedCard( + Game game, + std::uint32_t cardId, + const std::string& setName, + const std::string& cardName, + const std::vector& imageNames); + + [[nodiscard]] std::filesystem::path resolveImagePath(Game game, + const std::string& imageName) const; + +private: + IImageStore& store_; +}; + +} // namespace ccm diff --git a/core/include/ccm/services/SetService.hpp b/core/include/ccm/services/SetService.hpp new file mode 100644 index 0000000..80d4772 --- /dev/null +++ b/core/include/ccm/services/SetService.hpp @@ -0,0 +1,38 @@ +#pragma once + +// SetService: high-level operations for fetching and caching set data. +// Wraps an ISetRepository (cache) and dispatches to the correct ISetSource +// based on the active IGameModule. + +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/Set.hpp" +#include "ccm/games/IGameModule.hpp" +#include "ccm/ports/ISetRepository.hpp" +#include "ccm/util/Result.hpp" + +#include +#include + +namespace ccm { + +class SetService { +public: + explicit SetService(ISetRepository& repo); + + // Register a game module so this service can route fetch requests. + // Pointer must remain valid for the lifetime of the SetService. + void registerModule(IGameModule* module); + + // Force a fresh fetch from the API for `game`, persist it via the + // repository, and return the new list. + Result> updateSets(Game game); + + // Cached read; returns an error if no local data exists yet. + Result> getSets(Game game); + +private: + ISetRepository& repo_; + std::unordered_map modules_; +}; + +} // namespace ccm diff --git a/core/include/ccm/util/FsNames.hpp b/core/include/ccm/util/FsNames.hpp new file mode 100644 index 0000000..1899ba9 --- /dev/null +++ b/core/include/ccm/util/FsNames.hpp @@ -0,0 +1,24 @@ +#pragma once + +// Pure functions for filename munging. Ported from the original `util/fs.rs` so +// that file naming on disk stays identical between the two codebases. + +#include +#include +#include + +namespace ccm { + +// Replace problematic characters so the result is safe to use as a filename +// component. Mirrors `format_text_for_fs` in Rust (drops apostrophes/commas, +// strips spaces, replaces `:` with `-`, `&` with `And`, `|` with `Or`, +// flattens accented vowels). Pure; safe at any call site. +std::string formatTextForFs(std::string_view text); + +// Parse the trailing 1- or 2-digit numeric index from a filename. Examples: +// "Image1.png" -> 1 +// "Image22.jpeg" -> 22 +// Returns 0 on parse failure, matching the optimistic Rust behavior. +std::uint8_t parseIndexFromFilename(std::string_view filename) noexcept; + +} // namespace ccm diff --git a/core/include/ccm/util/Result.hpp b/core/include/ccm/util/Result.hpp new file mode 100644 index 0000000..6e4e696 --- /dev/null +++ b/core/include/ccm/util/Result.hpp @@ -0,0 +1,150 @@ +#pragma once + +// A minimal Result type used as a sum-type for fallible operations. +// Mirrors the Rust `Result` style used in the original codebase. +// +// We intentionally do not depend on tl::expected or std::expected so this +// header stays buildable on every toolchain in our matrix (clang 14+, GCC 11+). + +#include +#include +#include +#include +#include + +namespace ccm { + +template +class Result { +public: + using value_type = T; + using error_type = E; + + static Result ok(T value) { return Result(OkTag{}, std::move(value)); } + static Result err(E error) { return Result(ErrTag{}, std::move(error)); } + + Result(const Result& other) : has_value_(other.has_value_) { + if (has_value_) { + ::new (static_cast(&value_)) T(other.value_); + } else { + ::new (static_cast(&error_)) E(other.error_); + } + } + + Result(Result&& other) noexcept : has_value_(other.has_value_) { + if (has_value_) { + ::new (static_cast(&value_)) T(std::move(other.value_)); + } else { + ::new (static_cast(&error_)) E(std::move(other.error_)); + } + } + + Result& operator=(const Result& other) { + if (this == &other) return *this; + destroy(); + has_value_ = other.has_value_; + if (has_value_) { + ::new (static_cast(&value_)) T(other.value_); + } else { + ::new (static_cast(&error_)) E(other.error_); + } + return *this; + } + + Result& operator=(Result&& other) noexcept { + if (this == &other) return *this; + destroy(); + has_value_ = other.has_value_; + if (has_value_) { + ::new (static_cast(&value_)) T(std::move(other.value_)); + } else { + ::new (static_cast(&error_)) E(std::move(other.error_)); + } + return *this; + } + + ~Result() { destroy(); } + + [[nodiscard]] bool isOk() const noexcept { return has_value_; } + [[nodiscard]] bool isErr() const noexcept { return !has_value_; } + explicit operator bool() const noexcept { return has_value_; } + + const T& value() const& { + if (!has_value_) throw std::logic_error("Result::value() on err"); + return value_; + } + T& value() & { + if (!has_value_) throw std::logic_error("Result::value() on err"); + return value_; + } + T&& value() && { + if (!has_value_) throw std::logic_error("Result::value() on err"); + return std::move(value_); + } + + const E& error() const& { + if (has_value_) throw std::logic_error("Result::error() on ok"); + return error_; + } + E&& error() && { + if (has_value_) throw std::logic_error("Result::error() on ok"); + return std::move(error_); + } + + template + T valueOr(U&& fallback) const& { + return has_value_ ? value_ : static_cast(std::forward(fallback)); + } + +private: + struct OkTag {}; + struct ErrTag {}; + + Result(OkTag, T value) : has_value_(true) { + ::new (static_cast(&value_)) T(std::move(value)); + } + Result(ErrTag, E error) : has_value_(false) { + ::new (static_cast(&error_)) E(std::move(error)); + } + + void destroy() noexcept { + if (has_value_) { + value_.~T(); + } else { + error_.~E(); + } + } + + bool has_value_; + union { + T value_; + E error_; + }; +}; + +// Specialization for void-returning fallible operations. +template +class Result { +public: + using value_type = void; + using error_type = E; + + static Result ok() { return Result(true, E{}); } + static Result err(E error) { return Result(false, std::move(error)); } + + [[nodiscard]] bool isOk() const noexcept { return has_value_; } + [[nodiscard]] bool isErr() const noexcept { return !has_value_; } + explicit operator bool() const noexcept { return has_value_; } + + const E& error() const& { + if (has_value_) throw std::logic_error("Result::error() on ok"); + return error_; + } + +private: + Result(bool ok, E error) : has_value_(ok), error_(std::move(error)) {} + bool has_value_; + E error_; +}; + +} // namespace ccm diff --git a/core/src/domain/Configuration.cpp b/core/src/domain/Configuration.cpp new file mode 100644 index 0000000..520c488 --- /dev/null +++ b/core/src/domain/Configuration.cpp @@ -0,0 +1,19 @@ +#include "ccm/domain/Configuration.hpp" + +namespace ccm { + +void to_json(nlohmann::json& j, const Configuration& c) { + j = nlohmann::json{ + {"dataStorage", c.dataStorage}, + {"defaultGame", c.defaultGame}, + {"theme", c.theme}, + }; +} + +void from_json(const nlohmann::json& j, Configuration& c) { + j.at("dataStorage").get_to(c.dataStorage); + j.at("defaultGame").get_to(c.defaultGame); + c.theme = j.value("theme", Theme::Light); +} + +} // namespace ccm diff --git a/core/src/domain/Enums.cpp b/core/src/domain/Enums.cpp new file mode 100644 index 0000000..97ee986 --- /dev/null +++ b/core/src/domain/Enums.cpp @@ -0,0 +1,139 @@ +#include "ccm/domain/Enums.hpp" + +#include +#include + +namespace ccm { + +std::string_view to_string(Game g) noexcept { + switch (g) { + case Game::Magic: return "Magic"; + case Game::Pokemon: return "Pokemon"; + } + return "Magic"; +} + +std::string_view to_string(Language l) noexcept { + switch (l) { + case Language::English: return "English"; + case Language::German: return "German"; + case Language::French: return "French"; + case Language::Spanish: return "Spanish"; + case Language::Italian: return "Italian"; + case Language::Chinese: return "Chinese"; + case Language::Japanese: return "Japanese"; + case Language::Russian: return "Russian"; + } + return "English"; +} + +std::string_view to_string(Condition c) noexcept { + switch (c) { + case Condition::Mint: return "Mint"; + case Condition::NearMint: return "NearMint"; + case Condition::Excellent: return "Excellent"; + case Condition::Good: return "Good"; + case Condition::LightPlayed: return "LightPlayed"; + case Condition::Played: return "Played"; + case Condition::Poor: return "Poor"; + } + return "Mint"; +} + +std::string_view to_string(Theme t) noexcept { + switch (t) { + case Theme::Light: return "Light"; + case Theme::Dark: return "Dark"; + } + return "Light"; +} + +std::optional gameFromString(std::string_view s) noexcept { + if (s == "Magic") return Game::Magic; + if (s == "Pokemon") return Game::Pokemon; + return std::nullopt; +} + +std::optional languageFromString(std::string_view s) noexcept { + if (s == "English") return Language::English; + if (s == "German") return Language::German; + if (s == "French") return Language::French; + if (s == "Spanish") return Language::Spanish; + if (s == "Italian") return Language::Italian; + if (s == "Chinese") return Language::Chinese; + if (s == "Japanese") return Language::Japanese; + if (s == "Russian") return Language::Russian; + return std::nullopt; +} + +std::optional conditionFromString(std::string_view s) noexcept { + if (s == "Mint") return Condition::Mint; + if (s == "NearMint") return Condition::NearMint; + if (s == "Excellent") return Condition::Excellent; + if (s == "Good") return Condition::Good; + if (s == "LightPlayed") return Condition::LightPlayed; + if (s == "Played") return Condition::Played; + if (s == "Poor") return Condition::Poor; + return std::nullopt; +} + +std::optional themeFromString(std::string_view s) noexcept { + if (s == "Light") return Theme::Light; + if (s == "Dark") return Theme::Dark; + return std::nullopt; +} + +const std::array& allGames() noexcept { + static constexpr std::array v{Game::Magic, Game::Pokemon}; + return v; +} + +const std::array& allLanguages() noexcept { + static constexpr std::array v{ + Language::English, Language::German, Language::French, Language::Spanish, + Language::Italian, Language::Chinese, Language::Japanese, Language::Russian + }; + return v; +} + +const std::array& allConditions() noexcept { + static constexpr std::array v{ + Condition::Mint, Condition::NearMint, Condition::Excellent, + Condition::Good, Condition::LightPlayed, Condition::Played, Condition::Poor + }; + return v; +} + +const std::array& allThemes() noexcept { + static constexpr std::array v{Theme::Light, Theme::Dark}; + return v; +} + +void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); } +void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); } +void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); } +void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); } + +void from_json(const nlohmann::json& j, Game& v) { + auto parsed = gameFromString(j.get()); + if (!parsed) throw std::invalid_argument("Unknown Game value: " + j.get()); + v = *parsed; +} +void from_json(const nlohmann::json& j, Language& v) { + auto parsed = languageFromString(j.get()); + if (!parsed) throw std::invalid_argument("Unknown Language value: " + j.get()); + v = *parsed; +} +void from_json(const nlohmann::json& j, Condition& v) { + auto parsed = conditionFromString(j.get()); + if (!parsed) throw std::invalid_argument("Unknown Condition value: " + j.get()); + v = *parsed; +} + +void from_json(const nlohmann::json& j, Theme& v) { + auto parsed = themeFromString(j.get()); + if (!parsed) throw std::invalid_argument("Unknown Theme value: " + j.get()); + v = *parsed; +} + +} // namespace ccm diff --git a/core/src/domain/MagicCard.cpp b/core/src/domain/MagicCard.cpp new file mode 100644 index 0000000..56a7858 --- /dev/null +++ b/core/src/domain/MagicCard.cpp @@ -0,0 +1,35 @@ +#include "ccm/domain/MagicCard.hpp" + +namespace ccm { + +void to_json(nlohmann::json& j, const MagicCard& c) { + j = nlohmann::json{ + {"id", c.id}, + {"amount", c.amount}, + {"name", c.name}, + {"set", c.set}, + {"note", c.note}, + {"images", c.images}, + {"language", c.language}, + {"condition", c.condition}, + {"foil", c.foil}, + {"signed", c.signed_}, + {"altered", c.altered}, + }; +} + +void from_json(const nlohmann::json& j, MagicCard& c) { + j.at("id").get_to(c.id); + j.at("amount").get_to(c.amount); + j.at("name").get_to(c.name); + j.at("set").get_to(c.set); + j.at("note").get_to(c.note); + j.at("images").get_to(c.images); + j.at("language").get_to(c.language); + j.at("condition").get_to(c.condition); + j.at("foil").get_to(c.foil); + j.at("signed").get_to(c.signed_); + j.at("altered").get_to(c.altered); +} + +} // namespace ccm diff --git a/core/src/domain/PokemonCard.cpp b/core/src/domain/PokemonCard.cpp new file mode 100644 index 0000000..22d4517 --- /dev/null +++ b/core/src/domain/PokemonCard.cpp @@ -0,0 +1,39 @@ +#include "ccm/domain/PokemonCard.hpp" + +namespace ccm { + +void to_json(nlohmann::json& j, const PokemonCard& c) { + j = nlohmann::json{ + {"id", c.id}, + {"amount", c.amount}, + {"name", c.name}, + {"set", c.set}, + {"setNo", c.setNo}, + {"note", c.note}, + {"images", c.images}, + {"language", c.language}, + {"condition", c.condition}, + {"firstEdition", c.firstEdition}, + {"holo", c.holo}, + {"signed", c.signed_}, + {"altered", c.altered}, + }; +} + +void from_json(const nlohmann::json& j, PokemonCard& c) { + j.at("id").get_to(c.id); + j.at("amount").get_to(c.amount); + j.at("name").get_to(c.name); + j.at("set").get_to(c.set); + j.at("setNo").get_to(c.setNo); + j.at("note").get_to(c.note); + j.at("images").get_to(c.images); + j.at("language").get_to(c.language); + j.at("condition").get_to(c.condition); + j.at("firstEdition").get_to(c.firstEdition); + j.at("holo").get_to(c.holo); + j.at("signed").get_to(c.signed_); + j.at("altered").get_to(c.altered); +} + +} // namespace ccm diff --git a/core/src/domain/Set.cpp b/core/src/domain/Set.cpp new file mode 100644 index 0000000..47402f2 --- /dev/null +++ b/core/src/domain/Set.cpp @@ -0,0 +1,19 @@ +#include "ccm/domain/Set.hpp" + +namespace ccm { + +void to_json(nlohmann::json& j, const Set& s) { + j = nlohmann::json{ + {"id", s.id}, + {"name", s.name}, + {"releaseDate", s.releaseDate}, + }; +} + +void from_json(const nlohmann::json& j, Set& s) { + j.at("id").get_to(s.id); + j.at("name").get_to(s.name); + j.at("releaseDate").get_to(s.releaseDate); +} + +} // namespace ccm diff --git a/core/src/games/magic/MagicCardPreviewSource.cpp b/core/src/games/magic/MagicCardPreviewSource.cpp new file mode 100644 index 0000000..74db794 --- /dev/null +++ b/core/src/games/magic/MagicCardPreviewSource.cpp @@ -0,0 +1,100 @@ +#include "ccm/games/magic/MagicCardPreviewSource.hpp" + +#include + +#include +#include +#include + +namespace ccm { + +namespace { + +// Percent-encode all bytes that are not unreserved per RFC 3986 +// (A-Z / a-z / 0-9 / - . _ ~). Spaces become %20, quotes become %22, etc. +// Used to keep Scryfall's `q=...` parameter syntactically valid through cpr, +// which does not URL-encode the URL string we hand it. +std::string urlEncode(std::string_view in) { + std::ostringstream out; + out.fill('0'); + out << std::hex << std::uppercase; + for (unsigned char c : in) { + const bool unreserved = + (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '.' || c == '_' || c == '~'; + if (unreserved) { + out << static_cast(c); + } else { + out << '%'; + out.width(2); + out << static_cast(c); + } + } + return out.str(); +} + +// Apply the same name massaging as the legacy query path before sending. +std::string sanitizeName(std::string_view name) { + std::string s(name); + std::string::size_type pos = 0; + while ((pos = s.find('&', pos)) != std::string::npos) { + s.replace(pos, 1, "and"); + pos += 3; + } + return s; +} + +} // namespace + +MagicCardPreviewSource::MagicCardPreviewSource(IHttpClient& http) : http_(http) {} + +std::string MagicCardPreviewSource::buildSearchUrl(std::string_view name, + std::string_view setId) { + // Build the unencoded query first so the output matches what Scryfall + // would parse: name:"" AND set: + const std::string sanitized = sanitizeName(name); + std::string query = "name:\""; + query += sanitized; + query += "\" AND set:"; + query += std::string(setId); + return std::string("https://api.scryfall.com/cards/search?q=") + urlEncode(query); +} + +Result MagicCardPreviewSource::parseResponse(const std::string& body) { + try { + const auto j = nlohmann::json::parse(body); + if (!j.contains("data") || !j.at("data").is_array()) { + return Result::err("Scryfall response missing 'data' array."); + } + const auto& data = j.at("data"); + if (data.empty()) { + return Result::err("Scryfall returned no matching cards."); + } + const auto& first = data.at(0); + if (!first.contains("image_uris") || !first.at("image_uris").is_object()) { + // Double-faced cards expose image_uris on each face; there is no + // fallback for this and surfaces it as "no preview". + return Result::err("Card has no top-level image_uris."); + } + const auto& uris = first.at("image_uris"); + if (!uris.contains("normal") || !uris.at("normal").is_string()) { + return Result::err("Card has no 'normal' image variant."); + } + return Result::ok(uris.at("normal").get()); + } catch (const std::exception& e) { + return Result::err(std::string("Scryfall JSON parse error: ") + e.what()); + } +} + +Result MagicCardPreviewSource::fetchImageUrl(std::string_view name, + std::string_view setId, + std::string_view /*setNo*/) { + const std::string url = buildSearchUrl(name, setId); + auto resp = http_.get(url); + if (!resp) return Result::err(resp.error()); + return parseResponse(resp.value()); +} + +} // namespace ccm diff --git a/core/src/games/magic/MagicGameModule.cpp b/core/src/games/magic/MagicGameModule.cpp new file mode 100644 index 0000000..6dbdb18 --- /dev/null +++ b/core/src/games/magic/MagicGameModule.cpp @@ -0,0 +1,8 @@ +#include "ccm/games/magic/MagicGameModule.hpp" + +namespace ccm { + +MagicGameModule::MagicGameModule(IHttpClient& http) + : setSource_(http), previewSource_(http) {} + +} // namespace ccm diff --git a/core/src/games/magic/MagicSetSource.cpp b/core/src/games/magic/MagicSetSource.cpp new file mode 100644 index 0000000..9bca754 --- /dev/null +++ b/core/src/games/magic/MagicSetSource.cpp @@ -0,0 +1,48 @@ +#include "ccm/games/magic/MagicSetSource.hpp" + +#include + +#include +#include + +namespace ccm { + +MagicSetSource::MagicSetSource(IHttpClient& http) : http_(http) {} + +Result> MagicSetSource::parseResponse(const std::string& body) { + try { + const auto j = nlohmann::json::parse(body); + if (!j.contains("data") || !j.at("data").is_array()) { + return Result>::err("Scryfall response missing 'data' array."); + } + std::vector out; + out.reserve(j.at("data").size()); + for (const auto& entry : j.at("data")) { + // Filter out digital-only sets exactly like the Rust code. + const bool digital = entry.value("digital", false); + if (digital) continue; + + Set s; + s.id = entry.value("code", ""); + s.name = entry.value("name", ""); + // Scryfall returns "released_at" as YYYY-MM-DD; persisted data stores YYYY/MM/DD. + std::string releasedAt = entry.value("released_at", ""); + std::replace(releasedAt.begin(), releasedAt.end(), '-', '/'); + s.releaseDate = std::move(releasedAt); + out.push_back(std::move(s)); + } + std::sort(out.begin(), out.end(), + [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); + return Result>::ok(std::move(out)); + } catch (const std::exception& e) { + return Result>::err(std::string("Scryfall JSON parse error: ") + e.what()); + } +} + +Result> MagicSetSource::fetchAll() { + auto resp = http_.get(kEndpoint); + if (!resp) return Result>::err(resp.error()); + return parseResponse(resp.value()); +} + +} // namespace ccm diff --git a/core/src/games/pokemon/PokemonCardPreviewSource.cpp b/core/src/games/pokemon/PokemonCardPreviewSource.cpp new file mode 100644 index 0000000..e9cbf34 --- /dev/null +++ b/core/src/games/pokemon/PokemonCardPreviewSource.cpp @@ -0,0 +1,110 @@ +#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp" + +#include + +#include +#include +#include + +namespace ccm { + +namespace { + +// RFC 3986 percent-encoder for the search-query payload. Same rules as the +// Magic implementation; kept private so the two can drift independently if a +// future API requires it. +std::string urlEncode(std::string_view in) { + std::ostringstream out; + out.fill('0'); + out << std::hex << std::uppercase; + for (unsigned char c : in) { + const bool unreserved = + (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '.' || c == '_' || c == '~'; + if (unreserved) { + out << static_cast(c); + } else { + out << '%'; + out.width(2); + out << static_cast(c); + } + } + return out.str(); +} + +// Strip everything after the first '/' in a Pokemon collector number. +// The Pokemon TCG API expects `number:"4"`, but cards are commonly stored as +// `4/102`. Without this, no API match is found. +std::string normalizeNumber(std::string_view setNo) { + std::string s(setNo); + const auto slash = s.find('/'); + if (slash != std::string::npos) { + s = s.substr(0, slash); + } + return s; +} + +} // namespace + +PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {} + +std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name, + std::string_view setId, + std::string_view setNo) { + // Build the unencoded query first so the output matches what the Pokemon + // TCG search syntax expects: name:"" set.id: number:. + std::string query = "name:\""; + query += std::string(name); + query += "\""; + if (!setId.empty()) { + query += " set.id:"; + query += std::string(setId); + } + const std::string num = normalizeNumber(setNo); + if (!num.empty()) { + query += " number:"; + query += num; + } + return std::string("https://api.pokemontcg.io/v2/cards?q=") + urlEncode(query); +} + +Result PokemonCardPreviewSource::parseResponse(const std::string& body) { + try { + const auto j = nlohmann::json::parse(body); + if (!j.contains("data") || !j.at("data").is_array()) { + return Result::err("Pokemon TCG response missing 'data' array."); + } + const auto& data = j.at("data"); + if (data.empty()) { + return Result::err("Pokemon TCG returned no matching cards."); + } + const auto& first = data.at(0); + if (!first.contains("images") || !first.at("images").is_object()) { + return Result::err("Card has no 'images' object."); + } + const auto& images = first.at("images"); + if (images.contains("large") && images.at("large").is_string()) { + return Result::ok(images.at("large").get()); + } + if (images.contains("small") && images.at("small").is_string()) { + return Result::ok(images.at("small").get()); + } + return Result::err("Card has no 'large' or 'small' image variant."); + } catch (const std::exception& e) { + return Result::err( + std::string("Pokemon TCG JSON parse error: ") + e.what()); + } +} + +Result PokemonCardPreviewSource::fetchImageUrl(std::string_view name, + std::string_view setId, + std::string_view setNo) { + const std::string url = buildSearchUrl(name, setId, setNo); + auto resp = http_.get(url); + if (!resp) return Result::err(resp.error()); + return parseResponse(resp.value()); +} + +} // namespace ccm diff --git a/core/src/games/pokemon/PokemonGameModule.cpp b/core/src/games/pokemon/PokemonGameModule.cpp new file mode 100644 index 0000000..9992b83 --- /dev/null +++ b/core/src/games/pokemon/PokemonGameModule.cpp @@ -0,0 +1,8 @@ +#include "ccm/games/pokemon/PokemonGameModule.hpp" + +namespace ccm { + +PokemonGameModule::PokemonGameModule(IHttpClient& http) + : setSource_(http), previewSource_(http) {} + +} // namespace ccm diff --git a/core/src/games/pokemon/PokemonSetSource.cpp b/core/src/games/pokemon/PokemonSetSource.cpp new file mode 100644 index 0000000..6df985c --- /dev/null +++ b/core/src/games/pokemon/PokemonSetSource.cpp @@ -0,0 +1,45 @@ +#include "ccm/games/pokemon/PokemonSetSource.hpp" + +#include + +#include +#include + +namespace ccm { + +PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {} + +Result> PokemonSetSource::parseResponse(const std::string& body) { + try { + const auto j = nlohmann::json::parse(body); + if (!j.contains("data") || !j.at("data").is_array()) { + return Result>::err( + "Pokemon TCG API response missing 'data' array."); + } + std::vector out; + out.reserve(j.at("data").size()); + for (const auto& entry : j.at("data")) { + Set s; + s.id = entry.value("id", ""); + s.name = entry.value("name", ""); + // Pokemon TCG API already returns "releaseDate" in YYYY/MM/DD; + // no separator rewrite needed (cf. Scryfall's "released_at"). + s.releaseDate = entry.value("releaseDate", ""); + out.push_back(std::move(s)); + } + std::sort(out.begin(), out.end(), + [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); + return Result>::ok(std::move(out)); + } catch (const std::exception& e) { + return Result>::err( + std::string("Pokemon TCG JSON parse error: ") + e.what()); + } +} + +Result> PokemonSetSource::fetchAll() { + auto resp = http_.get(kEndpoint); + if (!resp) return Result>::err(resp.error()); + return parseResponse(resp.value()); +} + +} // namespace ccm diff --git a/core/src/infra/CprHttpClient.cpp b/core/src/infra/CprHttpClient.cpp new file mode 100644 index 0000000..e644f84 --- /dev/null +++ b/core/src/infra/CprHttpClient.cpp @@ -0,0 +1,30 @@ +#include "ccm/infra/CprHttpClient.hpp" + +#include + +#include + +namespace ccm { + +CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout) : timeout_(timeout) {} + +Result CprHttpClient::get(std::string_view url) { + cpr::Response r = cpr::Get( + cpr::Url{std::string(url)}, + cpr::Timeout{timeout_}, + // Identify ourselves; some APIs rate-limit unknown agents harshly. + cpr::Header{{"User-Agent", "card-collection-manager-3/0.1"}, + {"Accept", "application/json"}} + ); + + if (r.error) { + return Result::err("HTTP error: " + r.error.message); + } + if (r.status_code < 200 || r.status_code >= 300) { + return Result::err( + "HTTP " + std::to_string(r.status_code) + " from " + std::string(url)); + } + return Result::ok(std::move(r.text)); +} + +} // namespace ccm diff --git a/core/src/infra/JsonSetRepository.cpp b/core/src/infra/JsonSetRepository.cpp new file mode 100644 index 0000000..0842fcd --- /dev/null +++ b/core/src/infra/JsonSetRepository.cpp @@ -0,0 +1,41 @@ +#include "ccm/infra/JsonSetRepository.hpp" + +#include + +#include + +namespace ccm { + +namespace fs = std::filesystem; + +JsonSetRepository::JsonSetRepository(IFileSystem& fs, ConfigService& config, DirNameFn dirName) + : fs_(fs), config_(config), dirName_(std::move(dirName)) {} + +fs::path JsonSetRepository::setsPath(Game game) const { + return fs::path(config_.current().dataStorage) / dirName_(game) / "sets.json"; +} + +Result> JsonSetRepository::load(Game game) { + const auto p = setsPath(game); + if (!fs_.exists(p)) { + return Result>::err("Set list not yet downloaded for this game."); + } + auto text = fs_.readText(p); + if (!text) return Result>::err(text.error()); + try { + auto j = nlohmann::json::parse(text.value()); + return Result>::ok(j.get>()); + } catch (const std::exception& e) { + return Result>::err(std::string("sets.json parse error: ") + e.what()); + } +} + +Result JsonSetRepository::save(Game game, const std::vector& sets) { + const auto p = setsPath(game); + auto dir = fs_.ensureDirectory(p.parent_path()); + if (!dir) return dir; + const nlohmann::json j = sets; + return fs_.writeText(p, j.dump(2)); +} + +} // namespace ccm diff --git a/core/src/infra/LocalImageStore.cpp b/core/src/infra/LocalImageStore.cpp new file mode 100644 index 0000000..c1aae41 --- /dev/null +++ b/core/src/infra/LocalImageStore.cpp @@ -0,0 +1,43 @@ +#include "ccm/infra/LocalImageStore.hpp" + +#include + +namespace ccm { + +namespace fs = std::filesystem; + +LocalImageStore::LocalImageStore(IFileSystem& fs, ConfigService& config, DirNameFn dirName) + : fs_(fs), config_(config), dirName_(std::move(dirName)) {} + +fs::path LocalImageStore::gameImageDir(Game game) const { + return fs::path(config_.current().dataStorage) / dirName_(game) / "images"; +} + +Result LocalImageStore::copyIn(Game game, + const fs::path& srcPath, + const std::string& targetName) { + const auto dir = gameImageDir(game); + auto ensure = fs_.ensureDirectory(dir); + if (!ensure) return Result::err(ensure.error()); + + // Preserve the source's extension - the original Rust code does the same. + std::string ext = srcPath.extension().string(); + std::string finalName = targetName + ext; + const auto dest = dir / finalName; + + auto cp = fs_.copyFile(srcPath, dest, /*overwrite=*/true); + if (!cp) return Result::err(cp.error()); + return Result::ok(std::move(finalName)); +} + +Result LocalImageStore::remove(Game game, const std::string& imageName) { + const auto p = gameImageDir(game) / imageName; + if (!fs_.exists(p)) return Result::ok(); // be forgiving on stale entries + return fs_.remove(p); +} + +fs::path LocalImageStore::resolvePath(Game game, const std::string& imageName) const { + return gameImageDir(game) / imageName; +} + +} // namespace ccm diff --git a/core/src/infra/StdFileSystem.cpp b/core/src/infra/StdFileSystem.cpp new file mode 100644 index 0000000..aa485d2 --- /dev/null +++ b/core/src/infra/StdFileSystem.cpp @@ -0,0 +1,88 @@ +#include "ccm/infra/StdFileSystem.hpp" + +#include +#include +#include + +namespace ccm { + +namespace fs = std::filesystem; + +bool StdFileSystem::exists(const fs::path& p) const { + std::error_code ec; + return fs::exists(p, ec); +} + +bool StdFileSystem::isDirectory(const fs::path& p) const { + std::error_code ec; + return fs::is_directory(p, ec); +} + +Result StdFileSystem::ensureDirectory(const fs::path& p) { + std::error_code ec; + if (fs::exists(p, ec)) { + if (fs::is_directory(p, ec)) return Result::ok(); + return Result::err("Path exists but is not a directory: " + p.string()); + } + fs::create_directories(p, ec); + if (ec) return Result::err("create_directories failed: " + ec.message()); + return Result::ok(); +} + +Result StdFileSystem::readText(const fs::path& p) { + std::ifstream in(p, std::ios::binary); + if (!in) return Result::err("Unable to open file: " + p.string()); + std::ostringstream ss; + ss << in.rdbuf(); + if (!in && !in.eof()) return Result::err("Read error on: " + p.string()); + return Result::ok(ss.str()); +} + +Result StdFileSystem::writeText(const fs::path& p, std::string_view contents) { + std::error_code ec; + if (p.has_parent_path()) { + fs::create_directories(p.parent_path(), ec); + if (ec) return Result::err("create_directories failed: " + ec.message()); + } + std::ofstream out(p, std::ios::binary | std::ios::trunc); + if (!out) return Result::err("Unable to create file: " + p.string()); + out.write(contents.data(), static_cast(contents.size())); + if (!out) return Result::err("Write error on: " + p.string()); + return Result::ok(); +} + +Result StdFileSystem::copyFile(const fs::path& from, const fs::path& to, bool overwrite) { + std::error_code ec; + if (to.has_parent_path()) { + fs::create_directories(to.parent_path(), ec); + if (ec) return Result::err("create_directories failed: " + ec.message()); + ec.clear(); + } + const auto opt = overwrite ? fs::copy_options::overwrite_existing + : fs::copy_options::none; + fs::copy_file(from, to, opt, ec); + if (ec) return Result::err("copy_file failed: " + ec.message()); + return Result::ok(); +} + +Result StdFileSystem::remove(const fs::path& p) { + std::error_code ec; + fs::remove(p, ec); + if (ec) return Result::err("remove failed: " + ec.message()); + return Result::ok(); +} + +Result> StdFileSystem::listDirectory(const fs::path& p) { + std::error_code ec; + if (!fs::is_directory(p, ec)) { + return Result>::err("Not a directory: " + p.string()); + } + std::vector out; + for (const auto& entry : fs::directory_iterator(p, ec)) { + out.push_back(entry.path()); + } + if (ec) return Result>::err("directory_iterator: " + ec.message()); + return Result>::ok(std::move(out)); +} + +} // namespace ccm diff --git a/core/src/services/CardFilter.cpp b/core/src/services/CardFilter.cpp new file mode 100644 index 0000000..a16ff3f --- /dev/null +++ b/core/src/services/CardFilter.cpp @@ -0,0 +1,66 @@ +#include "ccm/services/CardFilter.hpp" + +#include "ccm/domain/Enums.hpp" + +#include +#include +#include + +namespace ccm { +namespace { + +// Plain ASCII tolower, same approach as CardSorter::asciiLower. The old JS path used +// String.prototype.toLowerCase() which on the realistic ASCII-only data set +// (English/German set names, Scryfall-fed labels, integer amounts) behaves +// identically. +std::string asciiLower(std::string_view s) { + std::string out; + out.reserve(s.size()); + for (char c : s) { + out.push_back(static_cast( + std::tolower(static_cast(c)))); + } + return out; +} + +bool containsLower(std::string_view haystack, std::string_view needleLower) { + return asciiLower(haystack).find(needleLower) != std::string::npos; +} + +} // namespace + +bool matchesMagicFilter(const MagicCard& card, std::string_view filter) { + // `""`.includes(filter) is true for filter == "" in JS; mirror that so the + // panel does not need a separate "no filter" branch. + if (filter.empty()) return true; + + const std::string needle = asciiLower(filter); + + // Order mirrors the MtgTable.tsx tableFields list (minus the boolean + // flag columns, which `applyFilter` skips). Stops on first match for the + // same short-circuit behavior as the JS for-loop with `break`. + if (containsLower(card.name, needle)) return true; + if (containsLower(card.set.name, needle)) return true; + if (containsLower(to_string(card.language), needle)) return true; + if (containsLower(to_string(card.condition), needle)) return true; + if (containsLower(std::to_string(card.amount), needle)) return true; + if (containsLower(card.note, needle)) return true; + return false; +} + +bool matchesPokemonFilter(const PokemonCard& card, std::string_view filter) { + if (filter.empty()) return true; + + const std::string needle = asciiLower(filter); + + if (containsLower(card.name, needle)) return true; + if (containsLower(card.set.name, needle)) return true; + if (containsLower(card.setNo, needle)) return true; + if (containsLower(to_string(card.language), needle)) return true; + if (containsLower(to_string(card.condition), needle)) return true; + if (containsLower(std::to_string(card.amount), needle)) return true; + if (containsLower(card.note, needle)) return true; + return false; +} + +} // namespace ccm diff --git a/core/src/services/CardPreviewService.cpp b/core/src/services/CardPreviewService.cpp new file mode 100644 index 0000000..80b5e9d --- /dev/null +++ b/core/src/services/CardPreviewService.cpp @@ -0,0 +1,34 @@ +#include "ccm/services/CardPreviewService.hpp" + +namespace ccm { + +CardPreviewService::CardPreviewService(IHttpClient& http) : http_(http) {} + +void CardPreviewService::registerModule(IGameModule& module) { + if (auto* src = module.cardPreviewSource(); src != nullptr) { + sources_[module.id()] = src; + } +} + +Result CardPreviewService::fetchPreviewBytes(Game game, + std::string_view name, + std::string_view setId, + std::string_view setNo) { + auto it = sources_.find(game); + if (it == sources_.end() || it->second == nullptr) { + return Result::err("No preview source registered for this game."); + } + auto url = it->second->fetchImageUrl(name, setId, setNo); + if (!url) return Result::err(url.error()); + auto bytes = http_.get(url.value()); + if (!bytes) return Result::err(bytes.error()); + return Result::ok(std::move(bytes).value()); +} + +Result CardPreviewService::fetchImageBytesByUrl(std::string_view url) { + auto bytes = http_.get(url); + if (!bytes) return Result::err(bytes.error()); + return Result::ok(std::move(bytes).value()); +} + +} // namespace ccm diff --git a/core/src/services/CardSorter.cpp b/core/src/services/CardSorter.cpp new file mode 100644 index 0000000..b6ee0a5 --- /dev/null +++ b/core/src/services/CardSorter.cpp @@ -0,0 +1,173 @@ +#include "ccm/services/CardSorter.hpp" + +#include "ccm/domain/Enums.hpp" + +#include +#include +#include +#include + +namespace ccm { +namespace { + +// The comparator lowercases strings before compare via String.toLowerCase()-style behavior. +// We use ASCII-only tolower; the original TS app processed the same fields and +// never special-cased Unicode either, so this stays byte-compatible for the +// realistic data set (English/German/etc. names already lowercase identically). +std::string asciiLower(std::string_view s) { + std::string out; + out.reserve(s.size()); + for (char c : s) { + out.push_back(static_cast( + std::tolower(static_cast(c)))); + } + return out; +} + +// Wrap a less-than predicate so that ascending=false flips its meaning, +// mirroring `byField(field, asc)` in TableTemplate.tsx. +template +auto directional(Less less, bool ascending) { + return [less, ascending](const auto& a, const auto& b) { + return ascending ? less(a, b) : less(b, a); + }; +} + +} // namespace + +void sortMagicCards(std::vector& cards, MagicSortColumn column, + bool ascending) { + switch (column) { + case MagicSortColumn::Name: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const MagicCard& a, const MagicCard& b) { + return asciiLower(a.name) < asciiLower(b.name); + }, ascending)); + break; + case MagicSortColumn::SetReleaseDate: + // Release dates are stored as "YYYY/MM/DD" so plain lexicographic + // compare is chronological. The legacy JS path lowercased strings anyway; we + // do the same for parity even though digits/'/' are unaffected. + std::stable_sort(cards.begin(), cards.end(), directional( + [](const MagicCard& a, const MagicCard& b) { + return asciiLower(a.set.releaseDate) < + asciiLower(b.set.releaseDate); + }, ascending)); + break; + case MagicSortColumn::Language: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const MagicCard& a, const MagicCard& b) { + return asciiLower(to_string(a.language)) < + asciiLower(to_string(b.language)); + }, ascending)); + break; + case MagicSortColumn::Condition: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const MagicCard& a, const MagicCard& b) { + return asciiLower(to_string(a.condition)) < + asciiLower(to_string(b.condition)); + }, ascending)); + break; + case MagicSortColumn::Amount: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const MagicCard& a, const MagicCard& b) { + return a.amount < b.amount; + }, ascending)); + break; + case MagicSortColumn::Foil: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const MagicCard& a, const MagicCard& b) { + return a.foil < b.foil; // false < true (asc puts unset first) + }, ascending)); + break; + case MagicSortColumn::Signed: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const MagicCard& a, const MagicCard& b) { + return a.signed_ < b.signed_; + }, ascending)); + break; + case MagicSortColumn::Altered: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const MagicCard& a, const MagicCard& b) { + return a.altered < b.altered; + }, ascending)); + break; + case MagicSortColumn::Note: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const MagicCard& a, const MagicCard& b) { + return asciiLower(a.note) < asciiLower(b.note); + }, ascending)); + break; + } +} + +void sortPokemonCards(std::vector& cards, PokemonSortColumn column, + bool ascending) { + switch (column) { + case PokemonSortColumn::Name: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const PokemonCard& a, const PokemonCard& b) { + return asciiLower(a.name) < asciiLower(b.name); + }, ascending)); + break; + case PokemonSortColumn::SetReleaseDate: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const PokemonCard& a, const PokemonCard& b) { + return asciiLower(a.set.releaseDate) < + asciiLower(b.set.releaseDate); + }, ascending)); + break; + case PokemonSortColumn::Language: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const PokemonCard& a, const PokemonCard& b) { + return asciiLower(to_string(a.language)) < + asciiLower(to_string(b.language)); + }, ascending)); + break; + case PokemonSortColumn::Condition: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const PokemonCard& a, const PokemonCard& b) { + return asciiLower(to_string(a.condition)) < + asciiLower(to_string(b.condition)); + }, ascending)); + break; + case PokemonSortColumn::Amount: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const PokemonCard& a, const PokemonCard& b) { + return a.amount < b.amount; + }, ascending)); + break; + case PokemonSortColumn::Holo: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const PokemonCard& a, const PokemonCard& b) { + return a.holo < b.holo; + }, ascending)); + break; + case PokemonSortColumn::FirstEdition: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const PokemonCard& a, const PokemonCard& b) { + return a.firstEdition < b.firstEdition; + }, ascending)); + break; + case PokemonSortColumn::Signed: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const PokemonCard& a, const PokemonCard& b) { + return a.signed_ < b.signed_; + }, ascending)); + break; + case PokemonSortColumn::Altered: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const PokemonCard& a, const PokemonCard& b) { + return a.altered < b.altered; + }, ascending)); + break; + case PokemonSortColumn::Note: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const PokemonCard& a, const PokemonCard& b) { + return asciiLower(a.note) < asciiLower(b.note); + }, ascending)); + break; + } +} + +} // namespace ccm diff --git a/core/src/services/ConfigService.cpp b/core/src/services/ConfigService.cpp new file mode 100644 index 0000000..043c462 --- /dev/null +++ b/core/src/services/ConfigService.cpp @@ -0,0 +1,47 @@ +#include "ccm/services/ConfigService.hpp" + +#include + +#include + +namespace ccm { + +ConfigService::ConfigService(IFileSystem& fs, + std::filesystem::path configFilePath, + std::filesystem::path defaultDataStorage) + : fs_(fs), + path_(std::move(configFilePath)), + defaultDataStorage_(std::move(defaultDataStorage)) {} + +Configuration ConfigService::makeDefault() const { + Configuration c; + // generic_string() always uses '/' separators - keeps the value portable + // across Windows / Unix and round-trip-friendly for tests and JSON. + c.dataStorage = defaultDataStorage_.generic_string(); + c.defaultGame = Game::Magic; + return c; +} + +Result ConfigService::initialize() { + if (!fs_.exists(path_)) { + current_ = makeDefault(); + return store(current_); + } + auto text = fs_.readText(path_); + if (!text) return Result::err(text.error()); + try { + auto j = nlohmann::json::parse(text.value()); + current_ = j.get(); + } catch (const std::exception& e) { + return Result::err(std::string("config.json parse error: ") + e.what()); + } + return Result::ok(); +} + +Result ConfigService::store(Configuration cfg) { + current_ = std::move(cfg); + const nlohmann::json j = current_; + return fs_.writeText(path_, j.dump(2)); +} + +} // namespace ccm diff --git a/core/src/services/ImageService.cpp b/core/src/services/ImageService.cpp new file mode 100644 index 0000000..e469590 --- /dev/null +++ b/core/src/services/ImageService.cpp @@ -0,0 +1,120 @@ +#include "ccm/services/ImageService.hpp" + +#include "ccm/util/FsNames.hpp" + +#include + +namespace ccm { + +ImageService::ImageService(IImageStore& store) : store_(store) {} + +std::uint8_t ImageService::nextImageIndex(const std::vector& existingImages) { + if (existingImages.empty()) return 0; + const std::string& last = existingImages.back(); + // Preserve the compatibility shim for legacy image filenames that + // contain "IMG_FRONT"/"IMG_BACK" markers - those start a fresh index. + if (last.find("IMG_FRONT") != std::string::npos || + last.find("IMG_BACK") != std::string::npos) { + return 0; + } + const std::uint8_t parsed = parseIndexFromFilename(last); + // Saturating +1 since we hand back uint8 just like the Rust version. + return parsed == 255 ? 255 : static_cast(parsed + 1); +} + +std::string ImageService::buildTargetName(bool newEntry, + std::uint32_t cardId, + const std::string& setName, + const std::string& cardName, + std::uint8_t index) { + const std::string set = formatTextForFs(setName); + const std::string card = formatTextForFs(cardName); + if (newEntry) { + return set + "+" + card + "+" + std::to_string(static_cast(index)); + } + return std::to_string(cardId) + "+" + set + "+" + card + "+" + + std::to_string(static_cast(index)); +} + +Result ImageService::addImage(Game game, + const std::filesystem::path& srcPath, + bool newEntry, + std::uint32_t cardId, + const std::string& setName, + const std::string& cardName, + const std::vector& existingImages) { + const auto idx = nextImageIndex(existingImages); + const auto target = buildTargetName(newEntry, cardId, setName, cardName, idx); + return store_.copyIn(game, srcPath, target); +} + +Result ImageService::removeImage(Game game, const std::string& imageName) { + return store_.remove(game, imageName); +} + +Result> ImageService::normalizeNamesForPersistedCard( + Game game, + std::uint32_t cardId, + const std::string& setName, + const std::string& cardName, + const std::vector& imageNames) { + const std::string idPrefix = std::to_string(cardId) + "+"; + std::vector normalized = imageNames; + struct RenameOp { + std::string oldName; + std::string newName; + }; + std::vector ops; + ops.reserve(imageNames.size()); + + for (std::size_t i = 0; i < imageNames.size(); ++i) { + const std::string& oldName = imageNames[i]; + if (oldName.starts_with(idPrefix)) { + continue; + } + const std::uint8_t idx = parseIndexFromFilename(oldName); + const std::filesystem::path oldPath(oldName); + const std::string ext = oldPath.extension().string(); + const std::string newBase = buildTargetName(false, cardId, setName, cardName, idx); + const std::string newName = newBase + ext; + if (newName == oldName) { + continue; + } + ops.push_back({oldName, newName}); + normalized[i] = newName; + } + + if (ops.empty()) { + return Result>::ok(std::move(normalized)); + } + + std::vector created; + created.reserve(ops.size()); + for (const auto& op : ops) { + auto copied = store_.copyIn(game, store_.resolvePath(game, op.oldName), + std::filesystem::path(op.newName).stem().string()); + if (!copied) { + for (const auto& createdName : created) { + (void)store_.remove(game, createdName); + } + return Result>::err(copied.error()); + } + created.push_back(copied.value()); + } + + for (const auto& op : ops) { + auto removed = store_.remove(game, op.oldName); + if (!removed) { + return Result>::err(removed.error()); + } + } + + return Result>::ok(std::move(normalized)); +} + +std::filesystem::path ImageService::resolveImagePath(Game game, + const std::string& imageName) const { + return store_.resolvePath(game, imageName); +} + +} // namespace ccm diff --git a/core/src/services/SetService.cpp b/core/src/services/SetService.cpp new file mode 100644 index 0000000..666bf01 --- /dev/null +++ b/core/src/services/SetService.cpp @@ -0,0 +1,27 @@ +#include "ccm/services/SetService.hpp" + +namespace ccm { + +SetService::SetService(ISetRepository& repo) : repo_(repo) {} + +void SetService::registerModule(IGameModule* module) { + if (module) modules_[module->id()] = module; +} + +Result> SetService::updateSets(Game game) { + auto it = modules_.find(game); + if (it == modules_.end() || it->second == nullptr) { + return Result>::err("No game module registered for this game."); + } + auto fetched = it->second->setSource().fetchAll(); + if (!fetched) return fetched; + auto saved = repo_.save(game, fetched.value()); + if (!saved) return Result>::err(saved.error()); + return fetched; +} + +Result> SetService::getSets(Game game) { + return repo_.load(game); +} + +} // namespace ccm diff --git a/core/src/util/FsNames.cpp b/core/src/util/FsNames.cpp new file mode 100644 index 0000000..5233cea --- /dev/null +++ b/core/src/util/FsNames.cpp @@ -0,0 +1,85 @@ +#include "ccm/util/FsNames.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ccm { + +namespace { + +// Replacements ordered exactly like the Rust source's chained .replace(...) so +// behavior is bit-identical for inputs that contain multiple of these chars. +struct Replacement { + std::string_view from; + std::string_view to; +}; + +constexpr std::array kReplacements{{ + {"'", ""}, + {"`", ""}, + {",", ""}, + {" ", ""}, + {":", "-"}, + {"&", "And"}, + {"|", "Or"}, + {"\xC3\xA1", "a"}, // a-acute (UTF-8) + {"\xC3\xA9", "e"}, // e-acute + {"\xC3\xAD", "i"}, // i-acute + {"\xC3\xB3", "o"}, // o-acute + {"\xC3\xBA", "u"}, // u-acute + {"\xC3\xBB", "u"}, // u-circumflex + // Remaining accented vowels appear in modern Scryfall data but were not + // listed in the Rust source. Keeping behavior 1:1 deliberately. +}}; + +void replaceAllInPlace(std::string& s, std::string_view from, std::string_view to) { + if (from.empty()) return; + std::string::size_type pos = 0; + while ((pos = s.find(from, pos)) != std::string::npos) { + s.replace(pos, from.size(), to); + pos += to.size(); + } +} + +} // namespace + +std::string formatTextForFs(std::string_view text) { + std::string out(text); + for (const auto& r : kReplacements) { + replaceAllInPlace(out, r.from, r.to); + } + return out; +} + +std::uint8_t parseIndexFromFilename(std::string_view filename) noexcept { + const auto dot = filename.find_last_of('.'); + if (dot == std::string_view::npos || dot == 0) return 0; + + // Walk backwards from the position before the dot, collecting digits. + std::size_t end = dot; + std::size_t begin = end; + while (begin > 0) { + unsigned char ch = static_cast(filename[begin - 1]); + if (std::isdigit(ch)) { + --begin; + // Rust source intentionally caps at 2-digit indices. + if (end - begin >= 2) break; + } else { + break; + } + } + if (begin == end) return 0; + + unsigned int value = 0; + for (std::size_t i = begin; i < end; ++i) { + value = value * 10 + static_cast(filename[i] - '0'); + } + if (value > 255) value = 255; + return static_cast(value); +} + +} // namespace ccm diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..e40aa8d --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,39 @@ +# AGENTS.md + +Long-form contributor documentation that lives outside the source tree. + +## Files + +- `adding-a-new-game.md` — the **canonical, end-to-end procedure** for adding a brand-new game module to the project. Covers required external resources (sets API, optional card-preview API, icons), domain type with stable JSON, set source + tests, optional card preview source + tests, `IGameModule` glue, `Game` enum + `dirNameForGame` wiring, deriving the three UI panel templates (`BaseCardListPanel`, `BaseCardEditDialog`, `BaseSelectedCardPanel`), the `IGameView` adapter, composition-root wiring in `app/main.cpp`, CMake additions, AGENTS.md updates, and the test checklist. +- `ci-cd-guide.md` — user-facing CI/CD and release procedure: feature/master workflow split, orchestrator + reusable platform workflow structure, versioning conventions, PR title guard for `master`, artifact naming, and a concrete "create a new release" checklist. +- `versioning.md` — dedicated versioning reference: branch+sha scheme for feature builds, semantic version bump rules for `master`, tag format, and embedded app version behavior. +- `windows-installer.md` — how the NSIS-based Windows installer (`scripts/installer.nsi`) is built and configured: the `APP_VERSION` define, where the version is surfaced (window title, branding, Programs and Features), installed sections, registry layout, and CI vs. local invocation. +- `dow-doc-build-locally.md` — complete local build/setup reference for Windows and Linux, including dependency management and troubleshooting. +- `intro-to-new-developers.md` — onboarding map for new contributors: architecture, folder responsibilities, guardrails, anti-patterns, and links to deeper docs. +- `testing-and-test-code-of-conduct.md` — testing workflow plus expected standards for writing and maintaining deterministic, hermetic, behavior-focused tests. +- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic and Pokemon modules, plus the runtime flow through `SetService` / `CardPreviewService` and the error-surface conventions. +- `README.md` — index page that clusters docs by area and links to all documents in this directory. + +## Subdirectories + +- `assets/images/` — static screenshots and other binary assets referenced from the documentation (currently `demo-mtg.png`, `demo-pkm.png`). Keep filenames stable so cross-doc links don't break, and prefer compressed PNG/JPEG over uncompressed formats. + +## Conventions + +- These docs are reference material for contributors, not user-facing release notes. Keep them precise and dated implicitly by the source state they describe. +- Code examples should be C++20 and quote real file paths from the repo. +- Do **not** introduce dependencies on a specific upcoming feature, hypothetical game, or unmerged branch. The procedure must always describe the current code as it is on `main`. + +## Required follow-ups + +- After changing per-game seams in `core/` (e.g. `IGameModule`, `ISetSource`, `ICardPreviewSource`, `CollectionService`, `SetService`, `CardPreviewService`, `ImageService`) you **must** update `adding-a-new-game.md` to keep the canonical procedure in sync. The same applies to the UI seams (`IGameView`, `BaseCardListPanel`, `BaseCardEditDialog`, `BaseSelectedCardPanel`) and the composition-root wiring in `app/main.cpp`. +- After changing the Magic or Pokemon set/preview adapters (`MagicSetSource`, `MagicCardPreviewSource`, `PokemonSetSource`, `PokemonCardPreviewSource`) — endpoints, response parsing, name/number normalization, or the info-vs-asset split — you **must** update `assets-and-info-apis.md` so the API reference matches the live behavior. +- After bumping a key dependency (`nlohmann/json`, `cpr`, `wxWidgets`, `doctest`) in a way that changes a public API used in the guide's examples, update those examples. +- After adding a new file under `docs/` (or a new entry under `docs/assets/images/`) you **must** add it to the file list above **and** to `README.md` so the index stays complete. +- Do **not** rename, move, or split this file without first updating every other `AGENTS.md` that points at it (root, `core/`, `ui_wx/`, `app/`, `tests/`). + +## Anti-patterns + +- Don't sneak hypothetical or in-progress games into the doc as concrete examples; the guide is meant to be game-agnostic and must read as such. +- Don't link to the original Rust/Tauri repo as the source of truth — it is a historical reference, not the spec. The C++ code under `core/`, `ui_wx/`, and `app/` is the spec. +- Don't duplicate the per-package `AGENTS.md` content here; cross-link instead. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2ca0d78 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,26 @@ +#documentation #contributors #ccm3 + +# Documentation Index + +This folder contains contributor documentation for Card Collection Manager 3. Start with [Intro for New Developers](intro-to-new-developers.md) if you are new to the repository, then use the category sections below to navigate to specific tasks. + +## CI/CD And Release + +- [ci-cd-guide.md](ci-cd-guide.md): CI workflows, merge guards, artifacts, and release execution. +- [versioning.md](versioning.md): feature-build version format, semantic release rules on `master`, and tag/app-version behavior. +- [windows-installer.md](windows-installer.md): how the NSIS-based Windows installer is built, configured, and versioned. + +## Build And Local Setup + +- [dow-doc-build-locally.md](dow-doc-build-locally.md): local build setup for Windows/Linux, dependency model, build options, and troubleshooting. + +## Onboarding & Development Workflows + +- [intro-to-new-developers.md](intro-to-new-developers.md): architecture orientation, boundaries, common pitfalls, and first-week workflow guidance. + +- [testing-and-test-code-of-conduct.md](testing-and-test-code-of-conduct.md): test workflow plus repository rules for deterministic, behavior-focused tests. + +- [adding-a-new-game.md](adding-a-new-game.md): canonical end-to-end procedure for adding a new game module across `core/`, `ui_wx/`, and `app/`. + +- [assets-and-info-apis.md](assets-and-info-apis.md): external info and asset APIs used by Magic/Pokemon modules and their runtime purpose. + diff --git a/docs/adding-a-new-game.md b/docs/adding-a-new-game.md new file mode 100644 index 0000000..f9fa6f8 --- /dev/null +++ b/docs/adding-a-new-game.md @@ -0,0 +1,473 @@ +#documentation #architecture #game-modules + +# Adding A New Game To Card Collection Manager + +This is the canonical end-to-end walkthrough for adding support for a new TCG to Card Collection Manager. The guide covers external API selection, core and UI integration, composition-root wiring, and required tests so a full implementation can land cleanly on `main`. + +**Quick Setup:** gather required external resources first, then follow sections in order and run the full verification checklist before opening a PR. + +The guide is prescriptive about file locations and seam shapes but game-agnostic in naming. Replace `` with your game type name and `` with the lowercase key used for on-disk directories and `dirName()`. Both Magic and Pokemon follow this structure; use their implementations as references when needed. + +Read the root `AGENTS.md`, `core/AGENTS.md`, `ui_wx/AGENTS.md`, `app/AGENTS.md`, and `tests/AGENTS.md` before starting. They define the architecture rules this guide is built on top of. + +--- + +## 1. Prerequisites: pick your external resources + +Before you write any C++, gather the following. The further along you discover that something is missing, the more work you throw away. + +### 1.1 Set list API (required) + +A public HTTP endpoint that returns the canonical set / expansion list for the game, with at least: + +- a stable identifier (`id`) — used as the on-disk and JSON key. Must be stable across API revisions. +- a human-readable name. +- a release date — used to sort the set picker chronologically. + +The endpoint must be callable without authentication, or you must accept a hard-coded API key (we do not currently expose a way to ask the user for one). It must support HTTPS. Plan for the response body to be JSON; we do not have an XML or CSV path. + +The release date may be in any format **as long as you can rewrite it to `YYYY/MM/DD` during parsing**, because the `Set` domain type stores it that way (see `core/include/ccm/domain/Set.hpp`) and the rest of the code assumes lexicographic comparison sorts chronologically. + +### 1.2 Card preview API (optional) + +A public HTTP endpoint that returns the URL of a card's preview image given some lookup key (typically `name`, `set id`, and possibly a printed collector number). If the game does not expose one, the UI will simply skip the remote preview and only show locally-stored images — the implementation is allowed to omit this seam entirely. + +A few traps to plan around now, before you write code: + +- **Lookup precision.** Some APIs return many ambiguous matches when you query by name only and require the set id (and sometimes the collector number) to disambiguate. Decide up front which fields make a search reliable enough to take the first result. +- **URL encoding.** All query strings must be RFC 3986 percent-encoded before they reach `IHttpClient::get` (`cpr::Url` does **not** re-encode). The Magic/Pokemon implementations have a private `urlEncode` helper you can copy. +- **Collector-number normalization.** Pokemon stores `4/102` but the API only accepts `4`. Whichever convention your domain type uses, normalize it inside `buildSearchUrl` so the wire format is whatever the API actually expects. Mismatches here produce empty result sets, which then look identical to "no preview available" and are very tedious to debug. + +### 1.3 Flag icons + +Identify any boolean flag columns the game needs (Magic: `Foil`, `Signed`, `Altered`; Pokemon: `Holo`, `1. Edition`, `Signed`, `Altered`). For each one that doesn't already exist, plan an SVG glyph. SVG art with a single fillable path works best — see `ui_wx/src/SvgIcons.cpp` for the established style. Re-use existing glyphs across games where the meaning is identical (`Signed` and `Altered` are shared between Magic and Pokemon). + +### 1.4 Domain shape decision + +Decide whether the new game can re-use an existing card type or needs its own. Re-use is allowed when **every** field has identical semantics; in practice, every game we have shipped has needed its own type because at least one flag or extra column differs (e.g. Pokemon adds `setNo`, `holo`, `firstEdition`). + +If you create a new type, freeze the JSON layout now. The root `AGENTS.md` rule is unambiguous: **JSON layout must stay byte-for-byte stable** once you ship. Pick names that match any pre-existing on-disk format (this app may inherit data from a previous tool), and decide which C++ field names need a JSON alias (the canonical example: the C++ field `signed_` maps to the JSON key `"signed"` because `signed` is a C++ keyword). + +--- + +## 2. Core: domain types and the `Game` enum + +Everything below this point assumes you have already gathered the resources from §1. + +### 2.1 Extend the `Game` enum + +Edit `core/include/ccm/domain/Enums.hpp`: + +- Add a new enumerator to `enum class Game`. +- Update the size of `allGames()` (`std::array`). + +Edit `core/src/domain/Enums.cpp`: + +- Add a `case` to `to_string(Game)`. +- Add a branch to `gameFromString(std::string_view)`. +- Add the new enumerator to the `allGames()` constexpr array. + +The `Game` enum is the only place in `core/` that hardcodes which games exist. Adding a new entry here is what makes the rest of the registries (`SetService`, `CardPreviewService`, `AppContext::gameViews`, `dirNameForGame`) accept it. + +### 2.2 Add the card domain type (only if needed) + +If you decided in §1.4 that an existing card type fits, skip this section. + +Otherwise, create `core/include/ccm/domain/Card.hpp`: + +- A `struct Card` with `id`, `amount`, `name`, `set`, `note`, `images`, `language`, `condition` (these seven fields are required — the UI templates assume them) and any game-specific extras. +- Forward-declare `to_json` and `from_json` for `nlohmann::json`. +- Add a defaulted `friend bool operator==(const Card&, const Card&) = default;` so the JSON round-trip test can compare values. + +Then create `core/src/domain/Card.cpp`: + +- Hand-roll `to_json` and `from_json` using `nlohmann::json`. **Do not** use `NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE` — the explicit form keeps JSON aliases visible and makes future stability bugs easier to catch in code review. + +A real example to cargo-cult from is `core/src/domain/PokemonCard.cpp`. Note how `signed` (the JSON key) maps to `signed_` (the C++ field), and how every JSON key is spelled out. If your game uses a printed collector number, follow Pokemon's lead and store it as `setNo` (string) so the format `"4/102"` can survive a round-trip even when the API only consumes `"4"`. + +### 2.3 Update the JSON round-trip test + +Open `tests/domain_json_tests.cpp`. Add a `TEST_CASE` that: + +1. Constructs a `Card` with **every** field non-default (including `set.id`, `set.name`, `set.releaseDate`). +2. Serializes it to `nlohmann::json`. +3. Asserts the JSON contains the expected keys with the expected literal spellings (in particular, any C++/JSON aliases like `"signed"`). +4. Round-trips it back into a `Card` and `CHECK(roundTripped == original)`. + +This is the **byte-for-byte stability gate**. If you skip it, the alias bugs only surface in production after you ship and someone's collection.json fails to parse. + +--- + +## 3. Core: set source and (optional) card preview source + +### 3.1 `SetSource` + +Mirrors `core/include/ccm/games/pokemon/PokemonSetSource.hpp` and the matching `.cpp`. Put your files at: + +- `core/include/ccm/games//SetSource.hpp` +- `core/src/games//SetSource.cpp` + +The header should declare: + +- `class SetSource final : public ISetSource` +- `static constexpr const char* kEndpoint = "";` +- `explicit SetSource(IHttpClient& http);` +- `Result> fetchAll() override;` +- `static Result> parseResponse(const std::string& body);` + +The static `parseResponse` is mandatory. It is the seam you unit-test (no HTTP, no fakes — just a string in, a `Result` out). `fetchAll()` is a thin wrapper that calls `http_.get(kEndpoint)` and forwards to `parseResponse` on success. + +In `parseResponse`: + +1. Parse the body with `nlohmann::json::parse(body)` inside a `try`/`catch (const std::exception&)` block. Throwing across the port boundary is forbidden; catch and return `Result>::err(...)` with a useful message. +2. Walk the response, mapping each entry to a `Set { id, name, releaseDate }`. Rewrite the release date to `YYYY/MM/DD` if the API uses a different format. +3. `std::sort` ascending by release date. +4. Return `Result>::ok(std::move(out))`. + +A note on quirky APIs: some endpoints return a top-level array, some wrap it in `{ "data": [...] }`, and some put it under a different key. The two reference implementations diverge on exactly this: Magic walks the Scryfall-shaped response, Pokemon walks `data[]`. Do whatever your API requires; it is fine for `parseResponse` to be game-specific. + +### 3.2 `CardPreviewSource` (optional) + +Skip this section if the game has no remote preview API. + +Mirror `core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp`. The header should declare: + +- `class CardPreviewSource final : public ICardPreviewSource` +- `explicit CardPreviewSource(IHttpClient& http);` +- `Result fetchImageUrl(std::string_view name, std::string_view setId, std::string_view setNo) override;` +- `static std::string buildSearchUrl(std::string_view name, std::string_view setId, std::string_view setNo);` +- `static Result parseResponse(const std::string& body);` + +Both `buildSearchUrl` and `parseResponse` are static and pure on purpose: every URL-encoding and JSON-shape rule is testable without HTTP. Common edge cases your tests must cover: + +- Names with spaces, punctuation, or non-ASCII characters (percent-encoding correctness). +- An empty `setId` (don't append the `set.id:` clause). +- An empty `setNo`, and a `setNo` that needs normalization (strip everything after `/`, strip leading zeros, etc.). +- Response with the preferred image variant present. +- Response with only the fallback image variant present. +- Empty `data[]` array. +- Malformed JSON (parse error path). + +`fetchImageUrl` is a thin wrapper: build URL → `http_.get(url)` → `parseResponse(body)`. + +### 3.3 `GameModule` + +Wire the two sources together. Create: + +- `core/include/ccm/games//GameModule.hpp` +- `core/src/games//GameModule.cpp` + +Header: + +```cpp +#pragma once + +#include "ccm/games/IGameModule.hpp" +#include "ccm/games//CardPreviewSource.hpp" // omit if no preview +#include "ccm/games//SetSource.hpp" + +namespace ccm { + +class GameModule final : public IGameModule { +public: + explicit GameModule(IHttpClient& http); + + [[nodiscard]] Game id() const noexcept override { return Game::; } + [[nodiscard]] std::string dirName() const override { return ""; } + [[nodiscard]] std::string displayName() const override { return ""; } + + ISetSource& setSource() override { return setSource_; } + // Omit the override below if the game has no remote preview API. + ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; } + +private: + SetSource setSource_; + CardPreviewSource previewSource_; +}; + +} // namespace ccm +``` + +Two subtle requirements: + +- `dirName()` returns the **on-disk directory name**. Once you ship, this is forever — changing it later orphans every existing user's data. Pick something lowercase, ASCII, and short. +- `cardPreviewSource()` defaults to `nullptr` in `IGameModule`. Only override it if you actually have a preview source. Returning `nullptr` makes `CardPreviewService::registerModule(*module)` a silent no-op for that game; the UI gracefully falls back to "no preview available". + +The `.cpp` is one line of constructor body — see `core/src/games/pokemon/PokemonGameModule.cpp`. + +### 3.4 Register the new sources in `core/CMakeLists.txt` + +There is no glob. Add the new `.cpp` files (set source, card preview source, game module, and the card domain `.cpp` if you added one) to the `add_library(ccm_core ...)` argument list. Configure the build before moving on; this catches any missing headers immediately. + +--- + +## 4. Core: tests for the new sources + +Writing the tests now, before the UI work, makes the next sections noticeably faster — every later UI debugging session benefits from already knowing the parser and the URL builder are correct. + +### 4.1 `_set_source_tests.cpp` + +Create `tests/_set_source_tests.cpp` modeled on `tests/pokemon_set_source_tests.cpp`. The required cases are: + +- `parseResponse` happy path with two or three sets, including the date rewrite if your API uses a non-`YYYY/MM/DD` format. +- `parseResponse` already-sorted output (input out of order, output ascending by release date). +- `parseResponse` empty array → empty `Result::ok(...)`. +- `parseResponse` missing top-level container → `Result::err(...)`. +- `parseResponse` malformed JSON → `Result::err(...)`. +- `fetchAll` happy path through a `FixedHttpClient` fake (in-file, ~10 lines — see the existing tests). Assert the `lastUrl` equals `kEndpoint`. +- `fetchAll` HTTP error → `Result::err(...)` propagation. + +### 4.2 `_card_preview_source_tests.cpp` (if you have a preview source) + +Create `tests/_card_preview_source_tests.cpp` modeled on `tests/pokemon_card_preview_source_tests.cpp`. The required cases are: + +- `buildSearchUrl` percent-encodes names with spaces and reserved characters. +- `buildSearchUrl` includes / omits the `setId` clause based on whether `setId` is empty. +- `buildSearchUrl` includes / omits / normalizes `setNo` according to your normalization rules. +- `parseResponse` returns the preferred image variant. +- `parseResponse` falls back to the secondary variant when the primary is absent. +- `parseResponse` errors on empty `data[]`, missing `images`, malformed JSON. +- `fetchImageUrl` round-trips through `FixedHttpClient` and asserts the URL was percent-encoded as expected. +- `fetchImageUrl` propagates HTTP errors. + +### 4.3 Register the new test files + +Add `_set_source_tests.cpp` (and `_card_preview_source_tests.cpp` if applicable) to `tests/CMakeLists.txt` `add_executable(ccm_core_tests ...)`. Build and run `ctest --test-dir build --output-on-failure`. **Do not** continue until these pass. + +### 4.4 Sorter / filter tests + +If you introduced a new card type in §2.2, add cases to `tests/card_sorter_tests.cpp` and `tests/card_filter_tests.cpp` covering the new sort columns and filter columns introduced by your domain type. The boolean-flag exclusion rule (filter only checks string/number columns; flag columns are skipped) must be covered explicitly so future refactors don't quietly break it. + +### 4.5 Set-service routing test + +Append a case to `tests/set_service_tests.cpp` that registers your new module alongside Magic and verifies that `updateSets(Game::)` does not perturb cached data for the other game. Routing isolation is what `SetService` exists for; one test per game keeps it honest. + +--- + +## 5. UI: derive from the three base templates + +The UI layer is built on three header-only class templates that own all the wxWidgets-specific machinery. Each has a small, well-documented set of virtual hooks; deriving for a new game is a hook-implementation exercise, not a wxWidgets exercise. Read `ui_wx/include/ccm/ui/Base*.hpp` once before starting. + +### 5.1 SVG icons + +If your game introduces flag columns whose glyphs do not already exist in `ui_wx/include/ccm/ui/SvgIcons.hpp`, add them now: + +- Declare each new icon as `extern const char* const kSvg;` in the header. +- Define them in `ui_wx/src/SvgIcons.cpp`. Keep the `@FILL@` placeholder so the rasterizer can substitute the active palette text color at draw time. **Do not** bake a color into the SVG — that breaks dark mode. +- Re-use existing glyphs (`kSvgSigned`, `kSvgAltered`, `kSvgFoil`, `kSvgHolo`) when the meaning matches. + +### 5.2 Sort and filter helpers + +Add a `SortColumn` enum to `core/include/ccm/services/CardSorter.hpp`, plus the corresponding `sortCards(std::vector<Card>&, SortColumn, bool)` declaration. Implement it in `core/src/services/CardSorter.cpp` mirroring the existing per-column dispatch (each enum entry maps to a comparator). + +Add `[[nodiscard]] bool matchesFilter(const Card&, std::string_view)` to `core/include/ccm/services/CardFilter.hpp` and implement it in `core/src/services/CardFilter.cpp`. Walk the same value-key columns that the list panel will display, lowercase both sides, and short-circuit on any substring hit. Boolean flag columns are intentionally excluded — only string/number columns participate in filtering. + +These functions are also the targets of §4.4's tests; you'll have already written the tests if you followed the order. + +### 5.3 `CardListPanel` + +Create: + +- `ui_wx/include/ccm/ui/CardListPanel.hpp` +- `ui_wx/src/CardListPanel.cpp` + +The header declares a `final class` deriving from `BaseCardListPanel<Card, SortColumn>`, with overrides for: + +- `declareTextColumns()` — return a `std::vector` of `{label, width, format, optional}`. The order is left-to-right on screen. The **last** entry must be the `Note` column; the base reserves it. +- `declareIconColumns()` — return a `std::vector` of `{svg, width, optional}`. These render between the leading text columns and the Note column. +- `renderTextCell(card, idx)` — return the cell text for the `idx`-th text column. The base passes index `0..textCols-1` for the leading rows and `textCols-1` for the Note row, so you usually `switch (idx)`. +- `isIconColumnSet(card, idx)` — return whether the `idx`-th icon column should render its glyph for this row. Index `0` is the first icon column declared in `declareIconColumns()`. +- `sortBy(column, ascending)` — call `sortCards(mutableCards(), column, ascending)`. **Use `mutableCards()`**, not `cards()`, because `sortBy` writes through the underlying vector. +- `matchesFilter(card, filter)` — call `matchesFilter(card, filter)`. + +In the constructor body, call `buildLayout()` (from `BaseCardListPanel`) so the base wires up the `wxListCtrl`, the themed header row, and the image lists. + +The reference implementation at `ui_wx/src/PokemonCardListPanel.cpp` is ~75 lines including the icon-column declaration and the `switch`-based renderer. Yours should land in the same ballpark. + +### 5.4 `SelectedCardPanel` + +Create: + +- `ui_wx/include/ccm/ui/SelectedCardPanel.hpp` +- `ui_wx/src/SelectedCardPanel.cpp` + +Inside the `.cpp`, define an unnamed-namespace `enum DetailKey : int { ... };` with one entry per detail row and one per flag column. Keep these names local — they're only used between this file's hook overrides. + +Override: + +- `declareDetailRows()` — return a `std::vector` of `{label, key, emptyLabel}`. The **first** row should typically be `Name`; its `emptyLabel` is what the panel shows when no card is selected. +- `declareFlagIcons()` — return a `std::vector` of `{svg, tooltip, key}`. +- `detailValueFor(card, key)` — `switch (key)` and return the appropriate string. **Also handle `kNoteKey`** (defined in `BaseSelectedCardPanel` as `-1`); the base calls `detailValueFor(card, kNoteKey)` to populate the bottom Note row. +- `isFlagSet(card, key)` — `switch (key)` and return the matching boolean. +- `previewKey(card)` — return `std::tuple` of `(name, setId, setNo)`. Use empty `setNo` for games whose preview API does not need a collector number. +- `gameId()` — return `Game::`. + +In the constructor body, call `buildLayout()` so the base wires up the preview area, detail grid, flag strip, and image list. + +The reference implementation is `ui_wx/src/PokemonSelectedCardPanel.cpp`. + +### 5.5 `CardEditDialog` + +Create: + +- `ui_wx/include/ccm/ui/CardEditDialog.hpp` +- `ui_wx/src/CardEditDialog.cpp` + +Derive from `BaseCardEditDialog<Card>`. Override: + +- `buildFlagsRow(wxBoxSizer* flagsBox)` — create your `wxCheckBox`es and `flagsBox->Add(...)` them. The base owns the surrounding `Flags` label and sizer. +- `appendExtraRows(wxFlexGridSizer* grid)` — only if your game has fields beyond the standard set. Use the inherited `appendRow(grid, label, ctrl)` helper. (Pokemon adds a `Set #` text input here.) +- `readExtraFromCard()` — copy fields from `constCard()` into your widgets. +- `writeExtraToCard()` — copy values from your widgets back into `mutableCard()`. +- `updateMenuName()` — return `"Update "`. This is what the dialog's "no sets cached" hint shows the user. + +In the constructor: + +1. Pass through to the `BaseCardEditDialog` constructor with the dialog title (e.g. `"Add Card"` or `"Edit Card"` based on `EditMode`), `imageService`, `setService`, `mode`, `std::move(initial)`, `Game::`, and the optional `preloadedSets` pointer. +2. Call `buildAndPopulate()` (from the base) to build the form, populate the choices, and call `readExtraFromCard()`. + +The reference implementation is `ui_wx/src/PokemonCardEditDialog.cpp`. + +### 5.6 `GameView` + +This is the polymorphic glue between the new game's panels and the rest of the app. Create: + +- `ui_wx/include/ccm/ui/GameView.hpp` +- `ui_wx/src/GameView.cpp` + +Derive from `IGameView`. The constructor takes references to the shared services (`ConfigService`, `SetService`, `ImageService`, `CardPreviewService`), the typed `CollectionService<Card>&`, and the `IGameModule&`. Members: + +- `CardListPanel* listPanel_{nullptr};` +- `SelectedCardPanel* selectedPanel_{nullptr};` +- `std::vector setsCache_;` — populated lazily by `setsForDialog()` so each Add/Edit open does not re-read from `SetService`. + +Implement the virtuals: + +- `gameId()` returns `Game::`. +- `displayName()` returns `""`. +- `listPanel(parent)` — lazily allocates the list panel as a child of `parent`; on first allocation, also `Bind(EVT_CARD_SELECTED, ...)` to push `listPanel_->selected()` into `selectedPanel_`. **The binding must live here**, in the typed `IGameView`, not in `MainFrame` — `MainFrame` only sees `IGameView` and never `Card`. +- `selectedPanel(parent)` — lazily allocates the selected panel. +- `refreshCollection()` — calls `collection_.list(Game::)`, handles errors with `wxMessageBox`, and pushes the new vector into `listPanel_->setCards(...)`. Also re-syncs the selected panel. +- `onAddCard(parent)`, `onEditCard(parent)`, `onDeleteCard(parent)` — open the typed `CardEditDialog` (or pop a confirm dialog for delete), call the typed `CollectionService` to commit, and refresh on success. +- `onUpdateSets(parent)` — calls `sets_.updateSets(Game::)`, refreshes `setsCache_`, returns a status string. +- `setFilter(filter)` — forwards to `listPanel_->setFilter(filter)`. +- `applyTheme(palette)` — forwards to both panels' `applyTheme`. +- `updateSetsMenuLabel()` — returns `"Update "`. This is what the `Sets` menu entry shows. + +The reference implementation is `ui_wx/src/PokemonGameView.cpp`. It's about 160 lines and is the same shape for every game. + +### 5.7 Register the new UI sources + +Add **all** new UI `.cpp` files to `ui_wx/CMakeLists.txt`: + +- `CardListPanel.cpp` +- `SelectedCardPanel.cpp` +- `CardEditDialog.cpp` +- `GameView.cpp` + +There is no glob. + +--- + +## 6. Composition root + +Edit `app/main.cpp` to wire the new game in. Read `app/AGENTS.md` first — destruction-order rules apply. + +### 6.1 New members + +Add `std::unique_ptr<...>` members to `CcmApp`. Order matters (destruction is reverse — deps before dependents): + +```cpp +std::unique_ptrGameModule> Mod_; +std::unique_ptrCard>> Repo_; +std::unique_ptrCard>> CollSvc_; +std::unique_ptrGameView> View_; +``` + +Place them next to the existing Magic/Pokemon members in the matching position — game module after `http_`, repo after the module, collection service after the repo and the image store, view at the end before `ctx_`. + +### 6.2 New constructions in `OnInit()` + +```cpp +Mod_ = std::make_uniqueGameModule>(*http_); + +Repo_ = std::make_uniqueCard>>( + *fs_, *config_, &dirNameForGame); + +CollSvc_ = std::make_uniqueCard>>( + *Repo_, *imgStore_); + +setSvc_->registerModule(Mod_.get()); +previewSvc_->registerModule(*Mod_); // no-op when the module has no preview source + +View_ = std::make_uniqueGameView>( + *config_, *CollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *Mod_); +``` + +### 6.3 `dirNameForGame` + +Add a `case ccm::Game::: return "";` arm. The string must match `GameModule::dirName()`. + +### 6.4 `AppContext` + +`AppContext` (`ui_wx/include/ccm/ui/AppContext.hpp`) currently holds explicit references to `magicModule` and `pokemonModule`. Add an `Module` reference field — keep the alphabetical / canonical order — and pass `*Mod_` for it in the `AppContext{...}` brace-init in `OnInit()`. Also append `View_.get()` to the `gameViews` vector. + +The `Game` and `Sets` menus in `MainFrame` are built dynamically from `gameViews`, so once the new view is in the vector its menu entries (the `Game > ` radio item and the `Sets > Update ` action) appear automatically. + +--- + +## 7. AGENTS.md and tests housekeeping + +After all the above compiles and tests pass: + +1. **`AGENTS.md`** (root) — the "After adding a new game module you must" required follow-up should already cover your work; read it and confirm. If you added a new domain type, the matching `tests/domain_json_tests.cpp` round-trip test is required (per `core/AGENTS.md`). +2. **`core/AGENTS.md`** — update only if your game required a new core seam shape (a new port, a new service, a new shared helper). Describing the new game itself is not required; the doc is meant to stay game-agnostic. +3. **`ui_wx/AGENTS.md`** — same: update only if you needed a new template hook or had to teach the `Base*` templates a new behaviour. Describing the new game's panel set is not required. +4. **`app/AGENTS.md`** — confirm the "Composition root is the only place" allowlist still mentions the concrete adapter types. If you added new ones (a new `GameView`, `GameModule`), append them. +5. **`tests/AGENTS.md`** — add the new test file names to the file map and the "Required follow-ups" list. +6. **This file** (`docs/adding-a-new-game.md`) — only edit when the procedure itself changes (new template hook, new service registration, new composition-root step). Do not insert your specific game's quirks here; capture those in code comments next to the relevant overrides. + +--- + +## 8. Verification checklist + +Run, in order, from the workspace root. Do not skip any step. + +1. **Configure**: `cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release` (or your usual generator). Configuration must succeed without warnings about a missing source file. +2. **Build**: `cmake --build build --parallel`. Must succeed cleanly. Pay close attention to template-instantiation errors — those usually indicate one of the `Base*` hooks is missing or wrongly-typed. +3. **Tests**: `ctest --test-dir build --output-on-failure`. Every existing test plus the new `_set_source_tests`, `_card_preview_source_tests`, the extended `domain_json_tests`, the extended `card_sorter_tests`, the extended `card_filter_tests`, and the extended `set_service_tests` must pass. +4. **Smoke test**: launch `./build/bin/ccm3` (or `.\build\bin\ccm3.exe`). Note: the CMake target is `ccm` but the executable is renamed to `ccm3` via `set_target_properties(... OUTPUT_NAME ccm3)`. + - The `Game` menu shows your new game alongside Magic and Pokemon and switching is instantaneous (no panel re-creation cost on subsequent switches). + - The `Sets > Update ` action fetches sets and reports a count. + - With sets cached, opening the new game's Add dialog populates the set picker and the dialog can be dismissed with `OK`. + - Adding, editing, and deleting a card all round-trip through disk: close and re-open the app and the card persists. + - Selecting a card kicks off a preview fetch (if the game has a preview source) and renders the image; the status line returns to `"Ready"` when the preview lands. + - The flag-icon strip shows / hides per card depending on which flags are set. + - Theme switching applies to all of the new game's panels (light → dark → light). + +--- + +## 9. Common traps + +These do not match a single seam in this guide but are worth calling out explicitly. + +- **Stale set caches.** Each `IGameView` caches `std::vector setsCache_`. After `onUpdateSets` succeeds, refresh the cache (assign the new vector). The reference implementations do this. +- **`signed_` / `signed`.** The C++ field is `signed_`; the JSON key is `"signed"`. This is intentional and must not be changed. The same convention applies to any new field where the natural name collides with a C++ keyword — pick a trailing-underscore C++ name and an unaliased JSON key. +- **Spacer column index.** `BaseCardListPanel` reserves index `0` for a hidden zero-width spacer column (MSW comctl32 image-list gutter workaround). Real columns start at index `1`. If you ever need to call into `wxListCtrl` directly from a derived panel (you should not), remember this. +- **Preview-fetch threading.** The async preview fetch in `BaseSelectedCardPanel` uses a `shared_ptr` + `std::atomic alive` + `std::atomic currentGen` triple. Do not capture `this` raw in any background work you add to a new game's selected panel; copy that pattern verbatim. +- **First-paint perf.** `MainFrame` defers initial collection load with `CallAfter(...)` and `BaseCardListPanel` defers the initial selection the same way. Don't move that work back into the constructor for "convenience" — it makes startup visibly slower. +- **`previewKey` for games without `setNo`.** If your preview API only needs `(name, setId)`, return an empty string for the third tuple element. The base will pass `""` through to the source, which is exactly what `MagicCardPreviewSource` is built to handle. +- **Filter exclusion.** The filter intentionally ignores boolean-flag columns. If you find yourself wanting `signed:true` style filters, that is a future feature, not a fix; don't smuggle it into `matchesFilter` without a design discussion. +- **Theming dialogs.** Always `applyThemeToWindowTree(&dlg, palette, theme)` before `ShowModal()` for any dialog you open. The reference `GameView::onAddCard` / `onEditCard` show the canonical pattern. + +--- + +## 10. Where to read first when something does not work + +- The new game compiles but its menu entries do not appear — check that the view was appended to `AppContext::gameViews` in `app/main.cpp`. +- The list panel is empty even after `Sets > Update ` succeeds — check `dirNameForGame`. The repository writes to `//collection.json`, and a typo here makes the load silently return an empty list on next launch. +- The filter input does nothing on the new game — check that `GameView::setFilter(...)` forwards to the list panel and that `matchesFilter` actually evaluates the active filter substring (the empty filter must match every row). +- The Add dialog shows `(no sets cached - use Sets > Update )` even after a successful update — `setsCache_` was not refreshed in `onUpdateSets`. The reference views assign `out.value()` into the cache. +- Preview never resolves — first add a unit test that hits `parseResponse` with a real captured response body. If that passes, log the URL `IHttpClient::get` is called with and try it in a browser or `curl`. Most "broken preview" bugs are URL encoding or a wrong shape in `buildSearchUrl`. +- Sort works but its arrow indicator is wrong — column `0` is the spacer, so the visual column index sort key cares about is one higher than you might expect. The base handles this; if it goes wrong, check that your `TextColumnSpec`/`IconColumnSpec` order matches `renderTextCell` / `isIconColumnSet` indexing exactly. +- Tests pass but the app crashes on shutdown — destruction order in `CcmApp` is wrong. Move `View_` so it is declared **after** `CollSvc_`, `setSvc_`, `imgSvc_`, `previewSvc_`, `Mod_` — the view must be torn down before any of its referenced services. diff --git a/docs/assets-and-info-apis.md b/docs/assets-and-info-apis.md new file mode 100644 index 0000000..4594bc6 --- /dev/null +++ b/docs/assets-and-info-apis.md @@ -0,0 +1,52 @@ +#documentation #apis #integrations #ccm3 + +# Asset And Info APIs + +This document explains which external APIs Card Collection Manager 3 uses, and what each API is responsible for in the app. Use this page with [adding-a-new-game.md](adding-a-new-game.md) when you are wiring a new game module or debugging API behavior. + +## API Roles + +The code separates remote APIs into two roles: **info APIs** and **asset APIs**. Info APIs provide set metadata used to populate local set lists (ID, name, release date). Asset APIs resolve a card lookup into an image URL, then `CardPreviewService` downloads the raw preview image bytes for the UI. + +## Magic: The Gathering APIs + +**Info API:** `https://api.scryfall.com/sets` +Used by `MagicSetSource` to fetch all sets. The parser drops digital-only sets, maps Scryfall fields to the internal `Set` type, rewrites `released_at` from `YYYY-MM-DD` to `YYYY/MM/DD`, and sorts ascending by release date. + +**Asset API:** `https://api.scryfall.com/cards/search?q=...` +Used by `MagicCardPreviewSource` to find a card printing from `name` + `setId`, then extract `data[0].image_uris.normal` as the preview URL. The search query is percent-encoded and card names apply `&` -> `and` normalization before lookup. + +## Pokemon APIs + +**Info API:** `https://api.pokemontcg.io/v2/sets` +Used by `PokemonSetSource` to fetch all sets. The parser maps `id`, `name`, and `releaseDate` directly into `Set`, then sorts ascending by release date. + +**Asset API:** `https://api.pokemontcg.io/v2/cards?q=...` +Used by `PokemonCardPreviewSource` to search by `name` plus optional `set.id` and collector number. It extracts `data[0].images.large` first and falls back to `images.small` if needed. + +The Pokemon source also normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote query expects only the printed number component. + +## Runtime Flow In CCM3 + +The app uses the same flow for both games: + +- `SetService` asks the game's `ISetSource` (info API) for the latest set list. +- `CardPreviewService` asks the game's `ICardPreviewSource` (asset API) for a preview image URL. +- `CardPreviewService` performs a second HTTP GET to that URL and returns raw bytes to the UI layer. +- If preview lookup fails (or returns empty bytes), the UI fetches a per-game fallback card-back image URL through `CardPreviewService::fetchImageBytesByUrl(...)` and shows that image in the selected-card preview panel. + +Current fallback image URLs (kept in `BaseSelectedCardPanel` for CCM2 parity): + +- Magic: `https://gamepedia.cursecdn.com/mtgsalvation_gamepedia/f/f8/Magic_card_back.jpg` +- Pokemon: `https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg` + +If a game module does not provide a preview source (`cardPreviewSource() == nullptr`), preview registration is skipped and the UI behaves as "no remote preview API available." + +## Error Surface And Debugging Intent + +Both source types return `Result` errors so failures cross boundaries without exceptions. In practice, this keeps failures debuggable by separating: + +- info API failures (bad set payload, schema mismatch, endpoint/network failure), and +- asset API failures (query mismatch, no matching card, missing image fields, image download failure). + +When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions (`data`, `image_uris`, `images.large`/`images.small`). If the fallback fetch succeeds, the panel intentionally shows the card-back image and the inline label `(image preview unavailable)`. diff --git a/docs/assets/images/demo-mtg.png b/docs/assets/images/demo-mtg.png new file mode 100644 index 0000000..bd5a78c Binary files /dev/null and b/docs/assets/images/demo-mtg.png differ diff --git a/docs/assets/images/demo-pkm.png b/docs/assets/images/demo-pkm.png new file mode 100644 index 0000000..8371562 Binary files /dev/null and b/docs/assets/images/demo-pkm.png differ diff --git a/docs/ci-cd-guide.md b/docs/ci-cd-guide.md new file mode 100644 index 0000000..57ecc82 --- /dev/null +++ b/docs/ci-cd-guide.md @@ -0,0 +1,90 @@ +#documentation #ci-cd #github-actions + +# CI/CD Guide + +This guide defines the CI/CD behavior for Card Collection Manager 3. For the complete versioning policy, including prefix semantics and tag rules, see [Versioning Guide](versioning.md). For how the Windows installer artifact is produced and configured, see [Windows Installer Guide](windows-installer.md). + +**Quick Setup:** protect `master` with the required check `Require semver prefix in PR title`, then keep release PR titles aligned with the accepted prefixes. + +## Workflow Map + +The repository uses GitHub Actions workflows split by branch intent, with one orchestrator per branch intent: + +- `feature-ci.yml`: single workflow run for non-`master` pushes; orchestrates Linux and Windows feature builds. +- `feature-linux.yml`: reusable Linux build/test/package workflow invoked by `feature-ci.yml`. +- `feature-windows.yml`: reusable Windows build/test/package workflow invoked by `feature-ci.yml`. +- `master-pr-title-guard.yml`: validate PR title prefixes for PRs targeting `master`. +- `master-ci.yml`: single workflow run on merged PRs to `master`; computes semver, invokes Windows reusable build, then tags/publishes release assets. +- `master-windows.yml`: reusable Windows build/test/package workflow invoked by `master-ci.yml`. + +## Version Flow + +Feature branches and `master` use different version modes because they solve different problems: feature builds need traceability to a commit, while `master` builds need stable semantic releases. + +### Feature Branch Builds + +On pushes to non-`master` branches, `feature-ci.yml` fans out to Linux and Windows reusable workflows. Each platform workflow computes a version string in the format `-` via `scripts/compute_feature_version.sh` (for example `feature-dark-mode-a1b2c3d`). + +That version is used in two places: + +- artifact file names +- app embedded version via `-DCCM_APP_VERSION=...` + +### Master Releases + +When a PR is merged into `master`, `master-ci.yml` computes the next semantic version from the latest semver tag and the PR title prefix using `scripts/compute_master_semver.sh`. + +Accepted prefix mapping: + +- `major...` -> major bump +- `minor...` -> minor bump +- `fix...` -> patch bump +- `patch...` -> patch bump +- `path...` -> patch bump (accepted alias in current setup) + +The resulting version is embedded in the app (`CCM_APP_VERSION`), used in artifact names, and tagged as `v`. + +## Master Merge Guard + +PRs targeting `master` must start with one of the accepted prefixes: + +- `major` +- `minor` +- `fix` +- `patch` +- `path` + +If a title does not match, `master-pr-title-guard.yml` fails and the PR should not be merged. + +## Artifact Naming + +Feature workflow artifacts (produced by jobs inside `feature-ci.yml`): + +- Windows: `ccm3-windows-.zip`, `ccm3-windows-installer-` +- Linux: `ccm3-linux-.zip` + +Master release artifacts (`master-ci.yml`, built via `master-windows.yml`): + +- `ccm3-windows-.zip` +- `ccm3-windows-installer-.exe` + +## Release Procedure + +Follow this flow for every release: + +1. Open a PR to `master`. +2. Use a valid semver prefix in the PR title (`major:`, `minor:`, `fix:`, `patch:`, or `path:`). +3. Wait for all required checks to pass. +4. Merge the PR. +5. Wait for `master-ci.yml` to complete semver computation, Windows build/test, tag creation, and release publishing. +6. Verify the `vX.Y.Z` tag and release assets in GitHub. + +## Local Build Version Behavior + +Outside CI, the app version defaults to `${PROJECT_VERSION} (localbuild)` through `CCM_APP_VERSION` in the top-level `CMakeLists.txt`. This keeps local binaries easy to distinguish from CI and release outputs. + +## Troubleshooting + +- **`msys2: command not found` in workflow logs:** ensure the MSYS2 setup step runs before jobs that use `shell: msys2 {0}`. +- **`Permission denied` while linking `ccm3.exe`:** the app is still running; close it and rebuild. +- **No release after merge:** verify the PR merged into `master` and used a valid prefix, then inspect `master-ci.yml` logs for version/tag/release failures. diff --git a/docs/dow-doc-build-locally.md b/docs/dow-doc-build-locally.md new file mode 100644 index 0000000..0eae24e --- /dev/null +++ b/docs/dow-doc-build-locally.md @@ -0,0 +1,129 @@ +#documentation #build #cmake + +# Build Locally Guide + +This guide explains how to build Card Collection Manager 3 on Windows and Linux, how dependencies are resolved, and how to run tests locally. For architecture orientation, see [Intro For New Developers](intro-to-new-developers.md). + +**Quick Setup:** run CMake configure, build, launch `ccm3`, then run `ctest` from the same build directory. + +## Build Model + +The project uses CMake and builds one desktop executable: + +- executable target: `ccm` (output binary: `ccm3` / `ccm3.exe`) +- language standard: C++20 +- layered targets: `ccm_core` (logic), `ccm_ui_wx` (wx UI), `ccm` (composition root) + +## Dependency Model + +Dependencies are managed with CMake `FetchContent` in `cmake/Dependencies.cmake`. + +Pinned versions: + +- `nlohmann/json` `v3.11.3` +- `libcpr/cpr` `1.10.5` +- `wxWidgets` `v3.2.5` +- `doctest` `v2.4.11` (only when tests are enabled) + +On first configure, CMake downloads sources. On first full build, heavy dependencies (especially wxWidgets and curl) build locally. Later builds reuse cached dependencies under `build/_deps`. + +## Use System wxWidgets + +By default, the build fetches wxWidgets. For faster local iteration with an installed wxWidgets, set `-DCCM_USE_SYSTEM_WX=ON`. + +## Prerequisites + +### Windows + +Recommended: Clang + Ninja + +- LLVM/Clang 14+ on `PATH` +- CMake 3.22+ +- Ninja on `PATH` + +Verified fallback: MSYS2 UCRT64 + MinGW-w64 GCC + +- MSYS2 UCRT64 toolchain (`gcc`, `cmake`, `make` or `ninja`) +- CMake 3.22+ + +MSVC is intentionally not supported. + +### Linux + +- CMake 3.22+ +- Clang or GCC with C++20 support +- Ninja recommended +- required system packages when using system wxWidgets (for example GTK/wx dev packages) + +## Build Commands + +Run from repository root. + +### Windows (Clang + Ninja) + +```powershell +cmake -S . -B build -G Ninja +cmake --build build --parallel +.\build\bin\ccm3.exe +``` + +### Windows (MinGW Makefiles) + +```powershell +cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release +cmake --build build --parallel 4 +.\build\bin\ccm3.exe +``` + +### Linux (Ninja) + +```bash +cmake -S . -B build -G Ninja +cmake --build build --parallel +./build/bin/ccm3 +``` + +## Build Options + +- `CCM_USE_SYSTEM_WX` (default `OFF`): use installed wxWidgets instead of fetched wxWidgets. +- `CCM_BUILD_TESTS` (default `ON`): build `ccm_core_tests`. +- `CMAKE_BUILD_TYPE` (commonly `Release`): standard CMake build type. +- `CCM_APP_VERSION` (default `${PROJECT_VERSION} (localbuild)`): app version string shown in About dialog. + +Example for faster local iteration: +```bash +cmake -S . -B build -DCCM_USE_SYSTEM_WX=ON -DCCM_BUILD_TESTS=OFF +cmake --build build +``` + +## Run Tests Locally + +From repository root: +```bash +cmake -S . -B build -DCCM_BUILD_TESTS=ON +cmake --build build --target ccm_core_tests +ctest --test-dir build --output-on-failure +``` + +Automated tests primarily cover `core/` and infrastructure adapters. UI testing is currently manual. + +## Runtime Notes + +### Windows Runtime DLLs + +`cpr` builds as shared, so `build/bin` contains runtime DLLs (for example `libcpr.dll`, `libcurl.dll`, `libzlib.dll`) next to `ccm3.exe`. + +For MinGW/MSYS2 builds, UCRT runtime DLLs must be available (typically via MSYS2 UCRT64 `bin` on `PATH`). + +## Troubleshooting + +- **First build is slow:** expected on cold dependency fetch/build, especially wxWidgets and curl. +- **`Permission denied` while linking `ccm3.exe`:** the app is still running; close it and rebuild. +- **Generator mismatch:** reuse the same generator for a build directory or create a new build directory. +- **Windows CI/local shell mismatch:** in CI jobs using `shell: msys2 {0}`, ensure MSYS2 setup runs before shell commands. + +## Related Docs + +- [CI/CD Guide](ci-cd-guide.md) +- [Versioning Guide](versioning.md) +- [Adding a new game to Card Collection Manager](adding-a-new-game.md) diff --git a/docs/intro-to-new-developers.md b/docs/intro-to-new-developers.md new file mode 100644 index 0000000..41358f3 --- /dev/null +++ b/docs/intro-to-new-developers.md @@ -0,0 +1,108 @@ +#documentation #onboarding #architecture + +# Intro For New Developers + +This document orients new contributors to Card Collection Manager 3. For local setup and build commands, start with [Build Locally Guide](dow-doc-build-locally.md); then use this guide to understand architecture boundaries and daily workflows. + +**Quick Setup:** read `AGENTS.md` files first, build once, run tests once, then make one small layer-scoped change to validate your environment. + +## Toolchain Snapshot + +Card Collection Manager 3 builds as a native C++ desktop binary with CMake. The project standard is C++20, with Clang as the preferred compiler on Windows and Linux, plus a verified MinGW-w64 fallback path on Windows. + +- build system: CMake 3.22+ with `FetchContent` +- language/toolchain: C++20, Clang preferred, MinGW-w64 GCC fallback on Windows +- UI toolkit: wxWidgets (`ui_wx/` only) +- REST/HTTP client library: `cpr` (libcurl-based, used through `IHttpClient`/`CprHttpClient`) +- JSON library: `nlohmann/json` +- test framework: `doctest` + +Use [Build Locally Guide](dow-doc-build-locally.md) for exact commands, generator options, runtime DLL notes, and troubleshooting details. + +## Project Shape + +Card Collection Manager 3 is a native desktop app written in C++20 with wxWidgets. The architecture is intentionally layered so core logic stays UI-agnostic. + +- `core/`: domain logic, services, and infrastructure adapters +- `ui_wx/`: wxWidgets UI code only +- `app/`: composition root that wires adapters, services, and views + +Dependency direction is strict: `app -> ui_wx -> core`. + +## Architecture Rules + +These rules are the baseline for all feature work: + +- `core/` must never include or depend on wxWidgets. +- UI code consumes services through `ccm::ui::AppContext`. +- Concrete adapter wiring belongs in `app/main.cpp`. +- JSON keys and aliases are contract-sensitive and must stay stable. + +## Repository Map + +### `core/` + +`core/` contains domain and non-UI behavior: + +- `domain/`: value types and enums (`MagicCard`, `PokemonCard`, `Set`, `Configuration`) +- `ports/`: seam interfaces (`IHttpClient`, `IFileSystem`, repositories, game seams) +- `services/`: use-case logic (`CollectionService`, `SetService`, `ConfigService`) +- `infra/`: concrete adapters (`Json*Repository`, `StdFileSystem`, `CprHttpClient`, `LocalImageStore`) +- `games/`: per-game modules (`magic`, `pokemon`) + +### `ui_wx/` + +`ui_wx/` contains all presentation code: + +- `MainFrame`: top-level shell and menu/split-view orchestration +- `BaseCardListPanel`, `BaseCardEditDialog`, `BaseSelectedCardPanel`: reusable UI templates +- `Magic*` and `Pokemon*` classes: game-specific view/panel implementations +- `Theme.cpp`, `SvgIcons.cpp`, `IconListCtrl.cpp`: theming and visual behavior + +### `app/` + +`app/main.cpp` is the composition root: + +- instantiate adapters, services, and modules +- register game modules and game views +- build `AppContext` +- create and show `MainFrame` + +Keep this file focused on wiring, not business logic. + +### `tests/` + +Tests target non-UI behavior with deterministic fakes and in-memory adapters. When a domain JSON contract or filesystem naming rule changes, update the matching tests in the same change. + +## Common Workflows + +### Add A Small Feature + +1. Identify the correct layer (`core`, `ui_wx`, or both). +2. Make the smallest coherent change in that layer. +3. Rebuild the affected target. +4. Run tests when core behavior changes. + +### Add A New Game + +Use [Adding a new game to Card Collection Manager](adding-a-new-game.md). Do not bypass the existing seams or invent parallel architecture for a new game. + +### Release-Oriented Changes + +Use [CI/CD Guide](ci-cd-guide.md) and [Versioning Guide](versioning.md) for workflow and release policy decisions. + +## Avoid These Pitfalls + +- adding wx headers in `core/` +- putting business logic in `app/main.cpp` +- accessing concrete adapters directly from UI code instead of `AppContext` +- changing JSON key spellings casually +- using unpinned dependency versions + +## First-Day Checklist + +1. Read repository `AGENTS.md` files (`core/`, `ui_wx/`, `app/`, `tests/`). +2. Build locally with [Build Locally Guide](dow-doc-build-locally.md). +3. Run the test suite once. +4. Make one small layer-contained change. +5. Rebuild and rerun relevant tests. diff --git a/docs/testing-and-test-code-of-conduct.md b/docs/testing-and-test-code-of-conduct.md new file mode 100644 index 0000000..b3e185e --- /dev/null +++ b/docs/testing-and-test-code-of-conduct.md @@ -0,0 +1,116 @@ +#documentation #testing #quality + +# Testing Guide And Test Code Of Conduct + +This guide defines how testing works in Card Collection Manager 3 and which standards test code must meet. For local build setup and toolchain prerequisites, see [Build Locally Guide](dow-doc-build-locally.md). + +**Quick Setup:** run `ccm_core_tests` from a clean build, keep tests hermetic, and update contract tests in the same change when contracts move. + +## Testing Focus + +The project prioritizes deterministic, fast, behavior-oriented testing. Most automated coverage intentionally targets `core/` logic and infrastructure/service behavior, while UI validation remains manual. + +## Test Setup + +- framework: `doctest` +- primary target: `ccm_core_tests` +- location: `tests/` +- default behavior: tests enabled via `CCM_BUILD_TESTS=ON` + +## Run Tests + +From repository root: +```bash +cmake -S . -B build -DCCM_BUILD_TESTS=ON +cmake --build build --target ccm_core_tests +ctest --test-dir build --output-on-failure +``` + +Windows and Linux use the same logical flow; only generator and compiler setup differ. + +## Coverage Surface + +Current automated tests cover non-UI behavior, including: + +- filesystem naming and parsing behavior +- domain JSON round-trip behavior +- service behavior (`CollectionService`, `ConfigService`, `SetService`) +- repository behavior with in-memory filesystem fakes +- game set-source parsing behavior + +## Manual UI Validation + +Because there is no UI automation, UI-affecting changes require manual checks: + +- theme switching (dark and light) +- dialog and popup behavior +- add/edit/delete card flows +- set update flows +- preview and image interactions + +For theming work, rebuild and run the final app target (`ccm`) instead of validating only static library targets. + +## Test Code Of Conduct + +### Keep Tests Hermetic + +- do not call real network services +- do not depend on local machine files +- use fakes and in-memory adapters where possible + +### Test Behavior, Not Internals + +- assert externally visible outcomes +- avoid brittle assertions tied to incidental implementation details +- prefer domain-level expectations over call-level trivia + +### Keep Tests Deterministic + +- no unseeded randomness +- no timing-sensitive assertions that can flap +- no ordering assumptions unless ordering is part of the contract + +### Keep Tests Readable + +- one intent per test case +- descriptive test names +- clear arrange/act/assert flow +- minimal abstraction for small tests + +### Update Tests With Contract Changes + +When contracts change, update tests in the same change: + +- domain JSON schema or aliases -> round-trip tests +- filename formatting or parsing -> filesystem naming tests +- service semantics -> matching service tests + +Behavior changes without aligned tests are incomplete. + +### Avoid Over-Mocking + +- prefer realistic fakes over mock-heavy tests +- mock at external boundaries only when needed +- preserve confidence in integration-shaped behavior paths + +### Keep Runtime Practical + +- keep suite runtime fast enough for frequent local execution +- avoid repeated expensive setup when shared setup works +- justify any expensive new suite and keep scope narrow + +## Review Checklist + +Before merging test changes, verify: + +- tests pass locally +- no new flakiness risk +- no external dependency introduced +- assertions reflect intended behavior +- failure messages are clear and actionable + +## Related Docs + +- [Build Locally Guide](dow-doc-build-locally.md) +- [Intro For New Developers](intro-to-new-developers.md) +- [Adding a new game to Card Collection Manager](adding-a-new-game.md) diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 0000000..7876aa9 --- /dev/null +++ b/docs/versioning.md @@ -0,0 +1,89 @@ +#documentation #versioning #releases + +# Versioning Guide + +This guide defines how Card Collection Manager 3 assigns versions in CI and release flows. For workflow wiring and release execution details, see [CI/CD Guide](ci-cd-guide.md). + +**Quick Setup:** choose a valid PR title prefix before opening a `master` PR because the prefix determines the release bump. + +## Versioning Model + +The project uses two versioning modes: + +- feature-build versioning for non-`master` branches +- semantic versioning for merged PRs into `master` + +## Feature-Build Versioning + +Non-`master` pushes use `-` (for example `feature-dark-theme-a1b2c3d` or `fix-sort-order-f91d2ab`). + +Rules: + +- branch names are sanitized and lowercased +- commit SHA is shortened to 7 characters +- computation runs in `scripts/compute_feature_version.sh` + +Usage: + +- artifact names +- app embedded version (`CCM_APP_VERSION`, visible in `Help -> About`) + +## Master Semantic Versioning + +Merged PRs into `master` use semantic versions in `MAJOR.MINOR.PATCH` format (for example `1.4.2`). + +CI computes the next version from the latest semver tag and the PR title prefix: + +- `major...` -> bump `MAJOR`, reset `MINOR` and `PATCH` to `0` +- `minor...` -> bump `MINOR`, reset `PATCH` to `0` +- `fix...` -> bump `PATCH` +- `patch...` -> bump `PATCH` +- `path...` -> bump `PATCH` (accepted alias in current setup) + +Computation runs in `scripts/compute_master_semver.sh`. + +## Validation Rules + +If the PR title prefix is not accepted, two controls fail by design: + +- PR title guard check for `master` PRs +- semantic-version script validation + +This enforcement keeps release bumps deterministic and reviewable. + +## Tag Format + +Master releases create git tags in this format: + +- `v` + +Examples: + +- `v1.0.0` +- `v2.3.7` + +## Embedded App Version + +The app embeds a build-time version string through CMake variable `CCM_APP_VERSION`. + +CI behavior: + +- feature workflows set it to `-` +- master release workflow sets it to semantic version + +Local/manual behavior: + +- default is `${PROJECT_VERSION} (localbuild)` unless overridden + +This default makes local binaries easy to distinguish from CI and release outputs. + +## PR Title Conventions + +Use explicit prefixes in this shape: + +- `major: ` +- `minor: ` +- `fix: ` +- `patch: ` + +Example: `minor: add custom themed confirmation dialogs`. diff --git a/docs/windows-installer.md b/docs/windows-installer.md new file mode 100644 index 0000000..d65d021 --- /dev/null +++ b/docs/windows-installer.md @@ -0,0 +1,128 @@ +#documentation #installer #nsis #windows + +# Windows Installer Guide + +This guide explains how the Windows installer for Card Collection Manager 3 is built, how it is configured, and how it cooperates with the rest of the build/release pipeline. For CI flow and artifact naming, see [CI/CD Guide](ci-cd-guide.md). For where the version string comes from, see [Versioning Guide](versioning.md). + +**Quick Setup:** install NSIS (`makensis` on `PATH`), build the app into `build/bin/`, then run `makensis -DAPP_VERSION="" scripts/installer.nsi` from the repository root. + +## Installer Model + +The installer is a single NSIS script: `scripts/installer.nsi`. It produces one self-contained executable that ships the entire `build/bin/` directory plus an embedded uninstaller. + +Key facts: + +- installer technology: NSIS (`makensis`) with the Modern UI 2 (MUI2) library +- output file: `ccm3-windows-installer.exe`, written to the repository root (resolved as `..\ccm3-windows-installer.exe` from `scripts/`) +- payload source: every file under `build/bin/` (resolved as `..\build\bin\*.*` from `scripts/`) +- icon: `scripts/installer_icon.ico` (resolved relative to `scripts/`) +- default install location: `%PROGRAMFILES64%\Card Collection Manager 3` +- elevation: `RequestExecutionLevel admin` + +Because the installer pulls from `build/bin/` directly, it must run **after** a successful release build that has been bundled with all required runtime DLLs (see [Build Locally Guide](dow-doc-build-locally.md) for what ends up in `build/bin/`). + +## Configuration Inputs + +Almost everything the installer needs is hard-coded in `scripts/installer.nsi`. The only configurable input is the version string, supplied at `makensis` time: + +- `APP_VERSION` — passed via `-DAPP_VERSION=""`. Falls back to `"localbuild"` if not provided, so manual local runs still work. + +This single value is reused in three visible places, so the installer, the uninstaller, and the OS Programs and Features entry all advertise the same version: + +- installer/uninstaller window title (NSIS `Name`): `Card Collection Manager 3 ` +- footer / branding text on every wizard page (NSIS `BrandingText`): `Card Collection Manager 3 ` +- Add/Remove Programs `DisplayVersion` registry value, so Windows shows the version in its own column + +The MUI welcome page and the uninstall confirm page reference `$(^Name)` internally, so embedding the version into `Name` is enough to make those pages say "Welcome to the Card Collection Manager 3 \ Setup Wizard" and "Card Collection Manager 3 \ will be uninstalled..." respectively, without any extra wiring. + +## Installed Sections + +The installer presents three sections on the Components page: + +- **Core files (required)** — read-only (`SectionIn RO`). Copies the full `build/bin/` payload into `$INSTDIR`, writes the uninstaller, and registers the Add/Remove Programs entry plus an `App Paths` entry so `ccm3` resolves from `Win+R`. +- **Start Menu shortcuts** — creates a shortcut at the top level of the Start Menu plus a `Card Collection Manager 3` folder containing both the app shortcut and an "Uninstall" shortcut. Uses `SetShellVarContext all` so shortcuts go to the all-users Start Menu. +- **Desktop shortcut** — creates a desktop shortcut for all users. + +Shortcut names intentionally do **not** include the version, so installing a newer version overwrites the existing shortcuts cleanly instead of leaving orphaned per-version entries behind. + +## Registry Layout + +The installer writes two registry roots, both under `HKLM` so an uninstall removes them cleanly regardless of which user launches it: + +- `Software\Microsoft\Windows\CurrentVersion\Uninstall\Card Collection Manager 3` (the Add/Remove Programs entry): + - `DisplayName` — product name + - `DisplayVersion` — value of `APP_VERSION` + - `DisplayIcon` — path to `ccm3.exe` + - `UninstallString` / `QuietUninstallString` — interactive and silent uninstall commands + - `InstallLocation` — `$INSTDIR` + - `NoModify` / `NoRepair` — both `1` (we do not implement modify/repair flows) +- `Software\Microsoft\Windows\CurrentVersion\App Paths\ccm3.exe`: + - default value — full path to `ccm3.exe` + - `Path` — `$INSTDIR` so child processes inherit DLL search rights + +The uninstall section (`Section "Uninstall"`) deletes both roots and removes the install directory and all created shortcuts. It uses `RMDir /r "$INSTDIR"` because the install dir is owned by the app. + +## Build Commands + +Run from the repository root. + +### Local manual build + +```powershell +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build build --parallel +makensis -DAPP_VERSION="0.1.0-localbuild" scripts/installer.nsi +``` + +The output is `ccm3-windows-installer.exe` at the repository root. With no `-DAPP_VERSION`, the installer self-labels as `localbuild` instead. + +### CI build + +Both Windows workflows install NSIS via MSYS2 (`mingw-w64-ucrt-x86_64-nsis`) and invoke the same script: + +```yaml +- name: Build Windows installer + run: makensis -DAPP_VERSION="${VERSION}" scripts/installer.nsi +``` + +The `${VERSION}` value comes from: + +- `scripts/compute_master_semver.sh` for merged `master` PRs (semantic version, e.g. `1.2.3`) +- `scripts/compute_feature_version.sh` for non-`master` branches (e.g. `feature-dark-mode-a1b2c3d`) + +The exact same `${VERSION}` is also passed to `cmake -DCCM_APP_VERSION=...`, so the installer/uninstaller, the Programs and Features entry, and the running app's About dialog always agree. + +## Artifact Names + +The CI artifacts produced from a single installer build are documented in [CI/CD Guide](ci-cd-guide.md), but for reference: + +- raw build output: `ccm3-windows-installer.exe` (in repo root, regardless of version) +- feature workflow artifact: `ccm3-windows-installer-` (folder containing the exe) +- master release asset: `ccm3-windows-installer-.exe` (renamed at release-asset packaging time) + +Renaming happens in the workflow's release-assets step, not in `installer.nsi`, so the script's `OutFile` is intentionally fixed. + +## Editing Rules + +When changing the installer: + +- edit `scripts/installer.nsi` and keep both `.github/workflows/feature-windows.yml` and `.github/workflows/master-windows.yml` invoking it the same way +- pass the version through `-DAPP_VERSION="${VERSION}"` so the installer, uninstaller, and Programs and Features stay in sync with `CCM_APP_VERSION` +- keep installer assets that should not be generated at runtime (such as `installer_icon.ico`) committed in `scripts/` +- keep shortcut display names version-agnostic so upgrades do not orphan old shortcuts +- if you add a new registry value to the uninstall key, mirror it in the `Section "Uninstall"` cleanup if it lives outside that key + +## Troubleshooting + +- **`makensis: command not found`:** install NSIS and ensure `makensis` is on `PATH`. In CI this is provided by the MSYS2 package `mingw-w64-ucrt-x86_64-nsis`. +- **`File: ... \build\bin\*.*` failures:** the build payload is missing. Run `cmake --build build --parallel` first and confirm `build/bin/ccm3.exe` plus the runtime DLLs exist (see [Build Locally Guide](dow-doc-build-locally.md)). +- **Installer self-labels as `localbuild` in CI:** `-DAPP_VERSION="${VERSION}"` was not passed, or `${VERSION}` was empty. Check that the workflow's version-computation step ran before the installer step and exported `VERSION`. +- **Programs and Features does not show a version:** the `DisplayVersion` registry write was skipped because the installer was built without `APP_VERSION` (or with an empty value). Rebuild with the define set. +- **Old shortcuts left behind after upgrade:** shortcut display names were changed (or were made version-specific). Restore the version-agnostic names so upgrades overwrite cleanly. +- **Uninstaller appears to leave files:** `RMDir /r "$INSTDIR"` does not remove files outside `$INSTDIR`. Anything written by the app at runtime under user profile paths is intentionally kept; the uninstaller only manages what the installer placed. + +## Related Docs + +- [CI/CD Guide](ci-cd-guide.md) +- [Versioning Guide](versioning.md) +- [Build Locally Guide](dow-doc-build-locally.md) diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md new file mode 100644 index 0000000..e1dac48 --- /dev/null +++ b/scripts/AGENTS.md @@ -0,0 +1,28 @@ +# AGENTS.md + +Utility scripts and CI helper assets live in this folder. + +## Scope + +- Keep CI-facing scripts and config here so workflows stay concise. +- Current contents: + - `installer.nsi` — NSIS installer definition used by the Windows CI job. + - `installer_icon.ico` — committed installer icon consumed by `installer.nsi`. + - `compute_feature_version.sh` — branch+sha version formatter for feature CI workflows. + - `compute_master_semver.sh` — semantic version bump logic for merged PRs to `master`. + +## Editing rules + +- Prefer referencing script files from workflows instead of embedding large inline scripts. +- Keep scripts deterministic and non-interactive so CI can run unattended. +- Keep versioning scripts strict and fail-fast (non-zero exit) when required title/version conventions are violated. +- When changing installer behavior, edit `installer.nsi` and ensure both `.github/workflows/feature-windows.yml` and `.github/workflows/master-windows.yml` still invoke it via `makensis -DAPP_VERSION="${VERSION}" scripts/installer.nsi`. +- Keep installer assets that should not be generated at runtime (such as icon files) committed in this folder. +- `installer.nsi` path semantics: + - `installer_icon.ico` is resolved relative to `scripts/`. + - build payload is loaded from `..\build\bin\*.*` (project root build output). + - installer output is written to `..\ccm3-windows-installer.exe` so CI upload paths can stay root-based. +- `installer.nsi` version semantics: + - `APP_VERSION` is supplied by CI (`makensis -DAPP_VERSION=...`) using the same `VERSION` value that is fed into `-DCCM_APP_VERSION` for the C++ build, so the installer, uninstaller, Add/Remove Programs entry, and the running app all advertise the same version. + - When `APP_VERSION` is not provided (manual local `makensis` runs), it defaults to `"localbuild"`. +- If a script depends on generated files, document those expectations in the script or workflow step that creates them. diff --git a/scripts/compute_feature_version.sh b/scripts/compute_feature_version.sh new file mode 100644 index 0000000..34eedad --- /dev/null +++ b/scripts/compute_feature_version.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +branch_name="$1" +commit_sha="$2" + +branch_safe="$(echo "${branch_name}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^-+|-+$//g')" +short_sha="$(echo "${commit_sha}" | cut -c1-7)" + +if [[ -z "${branch_safe}" ]]; then + branch_safe="branch" +fi + +echo "${branch_safe}-${short_sha}" diff --git a/scripts/compute_master_semver.sh b/scripts/compute_master_semver.sh new file mode 100644 index 0000000..5fd0424 --- /dev/null +++ b/scripts/compute_master_semver.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +pr_title="$(echo "$1" | tr '[:upper:]' '[:lower:]')" + +if [[ "${pr_title}" == major* ]]; then + bump="major" +elif [[ "${pr_title}" == minor* ]]; then + bump="minor" +elif [[ "${pr_title}" == fix* || "${pr_title}" == patch* || "${pr_title}" == path* ]]; then + bump="patch" +else + echo "Invalid PR title prefix. Must start with: major, minor, fix, patch, or path." >&2 + exit 1 +fi + +latest="$(git tag --list | sed -E 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -t. -k1,1nr -k2,2nr -k3,3nr | head -n1 || true)" +if [[ -z "${latest}" ]]; then + latest="0.0.0" +fi + +IFS='.' read -r major minor patch <<< "${latest}" + +case "${bump}" in + major) + major=$((major + 1)) + minor=0 + patch=0 + ;; + minor) + minor=$((minor + 1)) + patch=0 + ;; + patch) + patch=$((patch + 1)) + ;; +esac + +version="${major}.${minor}.${patch}" +release_tag="v${version}" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "version=${version}" + echo "release_tag=${release_tag}" + } >> "${GITHUB_OUTPUT}" +else + echo "${version}" +fi diff --git a/scripts/installer.nsi b/scripts/installer.nsi new file mode 100644 index 0000000..8c3125e --- /dev/null +++ b/scripts/installer.nsi @@ -0,0 +1,76 @@ +Unicode True +!include "MUI2.nsh" + +!define APP_NAME "Card Collection Manager 3" +!define APP_DIR "Card Collection Manager 3" +!define APP_EXE "ccm3.exe" +!define APP_ICON "installer_icon.ico" +!define REG_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Card Collection Manager 3" +!define APP_PATHS_KEY "Software\Microsoft\Windows\CurrentVersion\App Paths\ccm3.exe" + +; APP_VERSION is supplied by CI via `makensis -DAPP_VERSION=...`. Falls back to +; "localbuild" so manual runs from a developer machine still work. +!ifndef APP_VERSION + !define APP_VERSION "localbuild" +!endif + +Name "${APP_NAME} ${APP_VERSION}" +OutFile "..\ccm3-windows-installer.exe" +Icon "${APP_ICON}" +UninstallIcon "${APP_ICON}" +BrandingText "${APP_NAME} ${APP_VERSION}" +InstallDir "$PROGRAMFILES64\${APP_DIR}" +RequestExecutionLevel admin + +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_COMPONENTS +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH + +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES +!insertmacro MUI_LANGUAGE "English" + +Section "!Core files (required)" + SectionIn RO + SetOutPath "$INSTDIR" + File /r "..\build\bin\*.*" + WriteUninstaller "$INSTDIR\Uninstall.exe" + WriteRegStr HKLM "${REG_KEY}" "DisplayName" "${APP_NAME}" + WriteRegStr HKLM "${REG_KEY}" "DisplayVersion" "${APP_VERSION}" + WriteRegStr HKLM "${REG_KEY}" "DisplayIcon" "$INSTDIR\${APP_EXE}" + WriteRegStr HKLM "${REG_KEY}" "UninstallString" "$\"$INSTDIR\Uninstall.exe$\"" + WriteRegStr HKLM "${REG_KEY}" "QuietUninstallString" "$\"$INSTDIR\Uninstall.exe$\" /S" + WriteRegStr HKLM "${REG_KEY}" "InstallLocation" "$INSTDIR" + WriteRegDWORD HKLM "${REG_KEY}" "NoModify" 1 + WriteRegDWORD HKLM "${REG_KEY}" "NoRepair" 1 + WriteRegStr HKLM "${APP_PATHS_KEY}" "" "$INSTDIR\${APP_EXE}" + WriteRegStr HKLM "${APP_PATHS_KEY}" "Path" "$INSTDIR" +SectionEnd + +Section "Start Menu shortcuts" + SetShellVarContext all + CreateDirectory "$SMPROGRAMS\${APP_DIR}" + CreateShortcut "$SMPROGRAMS\${APP_NAME}.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0 + CreateShortcut "$SMPROGRAMS\${APP_DIR}\${APP_NAME}.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0 + CreateShortcut "$SMPROGRAMS\${APP_DIR}\Uninstall ${APP_NAME}.lnk" "$INSTDIR\Uninstall.exe" +SectionEnd + +Section "Desktop shortcut" + SetShellVarContext all + CreateShortcut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0 +SectionEnd + +Section "Uninstall" + SetShellVarContext all + Delete "$DESKTOP\${APP_NAME}.lnk" + Delete "$SMPROGRAMS\${APP_NAME}.lnk" + Delete "$SMPROGRAMS\${APP_DIR}\${APP_NAME}.lnk" + Delete "$SMPROGRAMS\${APP_DIR}\Uninstall ${APP_NAME}.lnk" + RMDir "$SMPROGRAMS\${APP_DIR}" + + RMDir /r "$INSTDIR" + DeleteRegKey HKLM "${REG_KEY}" + DeleteRegKey HKLM "${APP_PATHS_KEY}" +SectionEnd diff --git a/scripts/installer_icon.ico b/scripts/installer_icon.ico new file mode 100644 index 0000000..27384e4 Binary files /dev/null and b/scripts/installer_icon.ico differ diff --git a/tests/AGENTS.md b/tests/AGENTS.md new file mode 100644 index 0000000..53899c0 --- /dev/null +++ b/tests/AGENTS.md @@ -0,0 +1,58 @@ +# tests/AGENTS.md + +`ccm_core_tests` — doctest unit tests for `ccm_core`. Hermetic, fast, no real network or disk. Read the root `AGENTS.md` first. + +## File pointers + +- `main.cpp` — doctest entry point with `DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN`. Do not put tests here. +- `fakes/InMemoryFileSystem.{hpp,cpp}` — `IFileSystem` implementation backed by `std::map`. Normalizes paths via `lexically_normal().generic_string()` (always `/` separators). +- `fs_names_tests.cpp` — `formatTextForFs` + `parseIndexFromFilename`. Both are compatibility ports of the original Rust path rules. +- `domain_json_tests.cpp` — JSON round-trip tests for every domain type. **Update this file whenever a domain type changes.** +- `image_service_tests.cpp` — `ImageService` (uses inline `RecordingImageStore` fake). +- `collection_service_tests.cpp` — `CollectionService` (uses inline `InMemoryRepo` + `StubImageStore`). +- `config_service_tests.cpp` — `ConfigService` against `InMemoryFileSystem`. +- `json_collection_repository_tests.cpp`, `json_set_repository_tests.cpp` — repository round-trips against `InMemoryFileSystem`. +- `set_service_tests.cpp` — `SetService` with `FakeSetSource` + `InMemSetRepo`. +- `magic_set_source_tests.cpp` — `MagicSetSource::parseResponse` (Scryfall mapping). Drives `fetchAll` via `FixedHttpClient` fake. +- `magic_card_preview_source_tests.cpp` — `MagicCardPreviewSource::buildSearchUrl` URL-encoding rules + `parseResponse` (`data[0].image_uris.normal`). Drives `fetchImageUrl` via `FixedHttpClient`. +- `card_preview_service_tests.cpp` — `CardPreviewService` registry/orchestration through `registerModule(IGameModule&)` with an inline `FakeGameModule` returning a `FakeSource : ICardPreviewSource` and a `FixedHttpClient`. Pin-down for the "module returning nullptr is silently skipped" rule. +- `pokemon_set_source_tests.cpp` — `PokemonSetSource::parseResponse` (api.pokemontcg.io/v2/sets shape — `data[].id`, `name`, `releaseDate` already in `YYYY/MM/DD`) + sort-by-release-date stability. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL. +- `pokemon_card_preview_source_tests.cpp` — `PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`. +- `card_sorter_tests.cpp` — `sortMagicCards` / `sortPokemonCards` per-column behavior. Pin-down tests for `byField`-equivalent semantics: case-insensitive strings, chronological set sort via `set.releaseDate`, numeric `amount`, `false < true` boolean order, stable composition (sort by name then by set keeps inner-name order). Update this file whenever you add a new column / sort key. +- `card_filter_tests.cpp` — `matchesMagicFilter` / `matchesPokemonFilter` row-matcher behavior. Pin-down tests for `applyFilter`-equivalent semantics: case-insensitive substring match across `tableFields` valueKeys (name, set.name, language, condition, amount-as-string, note; Pokemon adds `setNo`), boolean flag columns (foil/signed/altered/holo/firstEdition) intentionally excluded, empty filter matches everything. Update this file whenever you add a new searchable column. +- `CMakeLists.txt` — explicit list of every `.cpp` (no glob). + +## Conventions + +1. **Framework**: doctest. Each test file `#include ` and uses `TEST_SUITE("...")` + `TEST_CASE("...")`. Asserts: `CHECK`, `REQUIRE`, `CHECK_THROWS`. +2. **No real I/O.** Everything goes through `ccm::testing::InMemoryFileSystem` or an inline test-local fake. If you need HTTP, write a fake `IHttpClient` like `FixedHttpClient` in `magic_set_source_tests.cpp`. +3. **Fakes for narrow concerns stay in the test file** as anonymous-namespace classes (e.g. `RecordingImageStore`, `InMemoryRepo`). Promote a fake to `tests/fakes/` only when more than one test file needs it. +4. **Path strings** in expectations must use forward slashes. The fake normalizes everything to `generic_string()`. Do not hard-code `\` separators. +5. **Test names** describe behavior, not implementation. Prefer "missing file is created with defaults" over "test_init_no_file". +6. **Add a `.cpp` to the `add_executable` call in `tests/CMakeLists.txt`.** No glob. + +## Required follow-ups + +- After modifying any domain type field or alias you **must** extend the matching test in `domain_json_tests.cpp`. +- After modifying `formatTextForFs` or `parseIndexFromFilename` you **must** extend `fs_names_tests.cpp` — these are byte-compatibility shims with the original Rust code. +- After adding a new service in `core/` you **must** add a corresponding `_service_tests.cpp` with at least the happy-path and one error-path test. +- After adding a new game's set source / card preview source you **must** add `tests/_set_source_tests.cpp` and (if applicable) `tests/_card_preview_source_tests.cpp` mirroring the Magic and Pokemon files. Add them to `tests/CMakeLists.txt`. + +## Commands + +- Configure with tests on: + `cmake -S . -B build -DCCM_BUILD_TESTS=ON` +- Build the suite: + `cmake --build build --target ccm_core_tests` +- Run all tests: + `ctest --test-dir build --output-on-failure` +- Run a single test by name pattern: + `./build/bin/ccm_core_tests --test-case="*nextImageIndex*"` +- Run a single suite: + `./build/bin/ccm_core_tests --test-suite="MagicSetSource::parseResponse"` + +## Anti-patterns + +- Don't depend on `ccm_ui_wx` from tests. UI is out of scope here. +- Don't rely on file paths existing on the host (no `/tmp`, no `C:\Users\...`). Use `InMemoryFileSystem`. +- Don't add tests that require network access. The Pokemon stub test checks the message, not a real API call. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..a9286c0 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,37 @@ +# Unit tests for ccm_core. Wx and infra-bound tests are out of scope here - +# everything in this target operates against the ccm_core ports/services using +# in-memory fakes, so the suite is fast and hermetic. + +add_executable(ccm_core_tests + fakes/InMemoryFileSystem.cpp + + fs_names_tests.cpp + domain_json_tests.cpp + image_service_tests.cpp + collection_service_tests.cpp + config_service_tests.cpp + json_collection_repository_tests.cpp + json_set_repository_tests.cpp + set_service_tests.cpp + magic_set_source_tests.cpp + magic_card_preview_source_tests.cpp + card_preview_service_tests.cpp + pokemon_set_source_tests.cpp + pokemon_card_preview_source_tests.cpp + card_sorter_tests.cpp + card_filter_tests.cpp + + main.cpp +) + +target_include_directories(ccm_core_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + +target_link_libraries(ccm_core_tests + PRIVATE + ccm_core + doctest::doctest + ccm_warnings +) + +include(${doctest_SOURCE_DIR}/scripts/cmake/doctest.cmake) +doctest_discover_tests(ccm_core_tests) diff --git a/tests/card_filter_tests.cpp b/tests/card_filter_tests.cpp new file mode 100644 index 0000000..c7a5479 --- /dev/null +++ b/tests/card_filter_tests.cpp @@ -0,0 +1,160 @@ +// CardFilter tests - exercises the per-row matcher ported from the table +// TableTemplate.tsx::applyFilter. Each TEST_CASE pins down a behavior the +// original UI relied on, so a regression here implies the C++ table no +// longer filters like the TypeScript reference. + +#include + +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/MagicCard.hpp" +#include "ccm/domain/PokemonCard.hpp" +#include "ccm/services/CardFilter.hpp" + +#include + +using namespace ccm; + +namespace { + +MagicCard mc(std::string name, + std::string setName, + std::uint8_t amount = 1, + Language lang = Language::English, + Condition cond = Condition::NearMint, + std::string note = "", + bool foil = false, bool sgnd = false, bool altered = false) { + MagicCard c; + c.id = 1; + c.name = std::move(name); + c.set.name = std::move(setName); + c.amount = amount; + c.language = lang; + c.condition = cond; + c.note = std::move(note); + c.foil = foil; + c.signed_ = sgnd; + c.altered = altered; + return c; +} + +PokemonCard pc(std::string name, + std::string setName, + std::string setNo = "", + std::uint8_t amount = 1, + std::string note = "") { + PokemonCard c; + c.id = 1; + c.name = std::move(name); + c.set.name = std::move(setName); + c.setNo = std::move(setNo); + c.amount = amount; + c.note = std::move(note); + return c; +} + +} // namespace + +TEST_SUITE("CardFilter::matchesMagicFilter") { + TEST_CASE("empty filter matches every row (mirrors JS \"\".includes(\"\"))") { + CHECK(matchesMagicFilter(mc("Brainstorm", "Alpha"), "")); + } + + TEST_CASE("matches by name (case-insensitive substring)") { + const MagicCard c = mc("Lightning Bolt", "Beta"); + CHECK(matchesMagicFilter(c, "lightning")); + CHECK(matchesMagicFilter(c, "BOLT")); // both sides lowercased + CHECK(matchesMagicFilter(c, "ning Bo")); // mid-string substring + CHECK_FALSE(matchesMagicFilter(c, "fireball")); + } + + TEST_CASE("matches by set.name (valueKey 'set.name', not 'set')") { + const MagicCard c = mc("Brainstorm", "Modern Horizons"); + CHECK(matchesMagicFilter(c, "Modern")); + CHECK(matchesMagicFilter(c, "horizons")); + CHECK_FALSE(matchesMagicFilter(c, "Zendikar")); + } + + TEST_CASE("matches by language label") { + const MagicCard c = mc("Brainstorm", "Alpha", 1, Language::Japanese); + CHECK(matchesMagicFilter(c, "japanese")); + CHECK_FALSE(matchesMagicFilter(c, "german")); + } + + TEST_CASE("matches by condition label") { + const MagicCard c = mc("Brainstorm", "Alpha", 1, + Language::English, Condition::Played); + CHECK(matchesMagicFilter(c, "played")); + CHECK_FALSE(matchesMagicFilter(c, "mint")); + } + + TEST_CASE("matches by amount as decimal string (val.toString())") { + // JS used `val.toString().toLowerCase().includes(filter)` so the + // *integer* amount becomes searchable as its decimal representation. + const MagicCard c = mc("Brainstorm", "Alpha", 17); + CHECK(matchesMagicFilter(c, "17")); + CHECK(matchesMagicFilter(c, "1")); // substring match: "1" in "17" + CHECK_FALSE(matchesMagicFilter(c, "99")); + } + + TEST_CASE("matches by note (case-insensitive)") { + const MagicCard c = mc("Brainstorm", "Alpha", 1, + Language::English, Condition::NearMint, + "Birthday gift"); + CHECK(matchesMagicFilter(c, "Birthday")); + CHECK(matchesMagicFilter(c, "GIFT")); + CHECK_FALSE(matchesMagicFilter(c, "trade")); + } + + TEST_CASE("boolean flag columns (foil / signed / altered) are NOT matched") { + // Filtering checks only string- or number-typed cells (typeof check). The + // bool flag columns therefore must not match the literal "true" / + // "false" — even when the flag is set. + const MagicCard c = mc("Brainstorm", "Alpha", 1, + Language::English, Condition::NearMint, "", + /*foil=*/true, /*sgnd=*/true, /*altered=*/true); + CHECK_FALSE(matchesMagicFilter(c, "true")); + CHECK_FALSE(matchesMagicFilter(c, "false")); + // ...unless the literal happens to be a substring of an actual + // string-typed column. Sanity-check that the matcher still fires on + // the name field with the same haystack. + CHECK(matchesMagicFilter(c, "brain")); + } + + TEST_CASE("filter is lowercased so uppercase input matches lowercased data") { + // The original path only lowercased the cell value, so "Brainstorm" filter against + // a card named "brainstorm" never matched. We lowercase both sides; + // pin that down here so a regression to the literal JS behavior gets + // caught. + const MagicCard c = mc("brainstorm", "alpha"); + CHECK(matchesMagicFilter(c, "BRAIN")); + CHECK(matchesMagicFilter(c, "Alpha")); + } +} + +TEST_SUITE("CardFilter::matchesPokemonFilter") { + TEST_CASE("matches by name and set.name") { + const PokemonCard c = pc("Charizard", "Base Set"); + CHECK(matchesPokemonFilter(c, "char")); + CHECK(matchesPokemonFilter(c, "BASE")); + CHECK_FALSE(matchesPokemonFilter(c, "pikachu")); + } + + TEST_CASE("Pokemon adds setNo to the searchable columns") { + // PokemonTable.tsx tableFields includes a "Set No" column whose + // valueKey is `setNo`. Pin that down — `setNo` is Pokemon-specific so + // the Magic matcher does not see it. + const PokemonCard c = pc("Charizard", "Base Set", "4/102"); + CHECK(matchesPokemonFilter(c, "4/102")); + CHECK(matchesPokemonFilter(c, "/102")); + } + + TEST_CASE("amount is searchable as decimal string for Pokemon too") { + const PokemonCard c = pc("Charizard", "Base Set", "4/102", 23); + CHECK(matchesPokemonFilter(c, "23")); + CHECK_FALSE(matchesPokemonFilter(c, "99")); + } + + TEST_CASE("empty filter matches everything") { + CHECK(matchesPokemonFilter(pc("Charizard", "Base Set"), "")); + } +} diff --git a/tests/card_preview_service_tests.cpp b/tests/card_preview_service_tests.cpp new file mode 100644 index 0000000..1295619 --- /dev/null +++ b/tests/card_preview_service_tests.cpp @@ -0,0 +1,152 @@ +#include + +#include "ccm/games/IGameModule.hpp" +#include "ccm/ports/ICardPreviewSource.hpp" +#include "ccm/ports/IHttpClient.hpp" +#include "ccm/services/CardPreviewService.hpp" + +#include + +using namespace ccm; + +namespace { + +class FakeSource final : public ICardPreviewSource { +public: + std::string url = "https://example.com/preview.jpg"; + bool ok = true; + std::string err = "boom"; + + // Capture inputs so tests can assert routing. + std::string lastName; + std::string lastSetId; + std::string lastSetNo; + + Result fetchImageUrl(std::string_view name, + std::string_view setId, + std::string_view setNo) override { + lastName = std::string(name); + lastSetId = std::string(setId); + lastSetNo = std::string(setNo); + return ok ? Result::ok(url) + : Result::err(err); + } +}; + +class FixedHttpClient final : public IHttpClient { +public: + std::string lastUrl; + std::string body; + bool ok = true; + std::string err = "offline"; + + Result get(std::string_view url) override { + lastUrl = std::string(url); + return ok ? Result::ok(body) + : Result::err(err); + } +}; + +// Minimal IGameModule fake that exposes a configurable preview source. +class FakeGameModule final : public IGameModule { +public: + Game gameId = Game::Magic; + ICardPreviewSource* preview = nullptr; + + [[nodiscard]] Game id() const noexcept override { return gameId; } + [[nodiscard]] std::string dirName() const override { return "fake"; } + [[nodiscard]] std::string displayName() const override { return "Fake"; } + + ISetSource& setSource() override { + // Tests in this file never call setSource(); a never-returned helper + // would clutter the fake. Throwing keeps misuse loud. + throw std::logic_error("FakeGameModule::setSource() not used in this test"); + } + ICardPreviewSource* cardPreviewSource() noexcept override { return preview; } +}; + +} // namespace + +TEST_SUITE("CardPreviewService::fetchPreviewBytes") { + TEST_CASE("happy path: routes through registered module then http GET") { + FakeSource source; + source.url = "https://example.com/img.png"; + FakeGameModule module; + module.gameId = Game::Magic; + module.preview = &source; + + FixedHttpClient http; + http.body = std::string("\x89PNG\r\n\x1a\n", 8); // arbitrary binary + + CardPreviewService svc{http}; + svc.registerModule(module); + + const auto out = svc.fetchPreviewBytes(Game::Magic, "Lightning Bolt", "lea", ""); + REQUIRE(out.isOk()); + CHECK(out.value() == http.body); + CHECK(http.lastUrl == "https://example.com/img.png"); + CHECK(source.lastName == "Lightning Bolt"); + CHECK(source.lastSetId == "lea"); + CHECK(source.lastSetNo.empty()); + } + + TEST_CASE("module returning nullptr preview source is skipped silently") { + FakeGameModule module; + module.gameId = Game::Pokemon; + module.preview = nullptr; + + FixedHttpClient http; + CardPreviewService svc{http}; + svc.registerModule(module); // no-op + + const auto out = svc.fetchPreviewBytes(Game::Pokemon, "Pikachu", "sv3", "1"); + CHECK(out.isErr()); + CHECK(out.error().find("No preview source registered") != std::string::npos); + } + + TEST_CASE("unregistered game returns an explicit error") { + FixedHttpClient http; + CardPreviewService svc{http}; + const auto out = svc.fetchPreviewBytes(Game::Pokemon, "Pikachu", "sv3", "1"); + CHECK(out.isErr()); + CHECK(out.error().find("No preview source registered") != std::string::npos); + } + + TEST_CASE("source error propagates and http is not called") { + FakeSource source; + source.ok = false; + source.err = "scryfall 404"; + FakeGameModule module; + module.gameId = Game::Magic; + module.preview = &source; + + FixedHttpClient http; + http.lastUrl = ""; + + CardPreviewService svc{http}; + svc.registerModule(module); + + const auto out = svc.fetchPreviewBytes(Game::Magic, "X", "abc", ""); + CHECK(out.isErr()); + CHECK(out.error() == "scryfall 404"); + CHECK(http.lastUrl == ""); + } + + TEST_CASE("http GET error propagates") { + FakeSource source; + FakeGameModule module; + module.gameId = Game::Magic; + module.preview = &source; + + FixedHttpClient http; + http.ok = false; + http.err = "net down"; + + CardPreviewService svc{http}; + svc.registerModule(module); + + const auto out = svc.fetchPreviewBytes(Game::Magic, "X", "abc", ""); + CHECK(out.isErr()); + CHECK(out.error() == "net down"); + } +} diff --git a/tests/card_sorter_tests.cpp b/tests/card_sorter_tests.cpp new file mode 100644 index 0000000..84c253a --- /dev/null +++ b/tests/card_sorter_tests.cpp @@ -0,0 +1,248 @@ +// CardSorter tests - exercises the per-column comparators ported from the +// TableTemplate.tsx::byField. Each TEST_CASE pins down a specific behavior +// the original UI relied on, so a regression here implies the C++ table no +// longer behaves like the TypeScript reference. + +#include + +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/MagicCard.hpp" +#include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/Set.hpp" +#include "ccm/services/CardSorter.hpp" + +#include +#include +#include + +using namespace ccm; + +namespace { + +MagicCard mc(std::uint32_t id, std::string name, + std::string setName, std::string releaseDate, + std::uint8_t amount = 1, + bool foil = false, bool sgnd = false, bool altered = false, + Language lang = Language::English, + Condition cond = Condition::NearMint, + std::string note = "") { + MagicCard c; + c.id = id; + c.name = std::move(name); + c.set.name = std::move(setName); + c.set.releaseDate = std::move(releaseDate); + c.amount = amount; + c.foil = foil; + c.signed_ = sgnd; + c.altered = altered; + c.language = lang; + c.condition = cond; + c.note = std::move(note); + return c; +} + +PokemonCard pc(std::uint32_t id, std::string name, + std::string setName, std::string releaseDate, + std::uint8_t amount = 1, + bool holo = false, bool firstEdition = false, + bool sgnd = false, bool altered = false) { + PokemonCard c; + c.id = id; + c.name = std::move(name); + c.set.name = std::move(setName); + c.set.releaseDate = std::move(releaseDate); + c.amount = amount; + c.holo = holo; + c.firstEdition = firstEdition; + c.signed_ = sgnd; + c.altered = altered; + return c; +} + +std::vector ids(const std::vector& v) { + std::vector out; + out.reserve(v.size()); + for (const auto& c : v) out.push_back(c.id); + return out; +} + +std::vector ids(const std::vector& v) { + std::vector out; + out.reserve(v.size()); + for (const auto& c : v) out.push_back(c.id); + return out; +} + +} // namespace + +TEST_SUITE("CardSorter - Magic columns") { + TEST_CASE("Name sorts case-insensitively (matches String.toLowerCase())") { + // Three cards whose names only differ in casing - if sort were a + // plain `<` this would put "ABC" before "abc" and wedge "Brainstorm" + // somewhere wrong. The JS path normalizes to lowercase first. + std::vector v = { + mc(1, "brainstorm", "X", "2000/01/01"), + mc(2, "ABC", "X", "2000/01/01"), + mc(3, "abc", "X", "2000/01/01"), + }; + sortMagicCards(v, MagicSortColumn::Name, /*ascending=*/true); + // ABC and abc tie under case-insensitive compare; stable sort keeps + // the input order (id 2 before id 3). + CHECK(ids(v) == std::vector{2, 3, 1}); + + sortMagicCards(v, MagicSortColumn::Name, /*ascending=*/false); + CHECK(ids(v) == std::vector{1, 2, 3}); + } + + TEST_CASE("Set column sorts by release date (chronological), not by name") { + // The whole point of having distinct valueKey/sortKey: the + // table *displays* set.name, but ascending order is chronological. + std::vector v = { + mc(1, "x", "Zendikar", "2009/10/02"), + mc(2, "y", "Alpha", "1993/08/05"), + mc(3, "z", "Modern Horizons","2019/06/14"), + }; + sortMagicCards(v, MagicSortColumn::SetReleaseDate, /*ascending=*/true); + CHECK(ids(v) == std::vector{2, 1, 3}); // 1993 < 2009 < 2019 + + sortMagicCards(v, MagicSortColumn::SetReleaseDate, /*ascending=*/false); + CHECK(ids(v) == std::vector{3, 1, 2}); + } + + TEST_CASE("Amount sorts numerically (no string-compare 10 < 2 trap)") { + std::vector v = { + mc(1, "a", "X", "2000/01/01", /*amount=*/10), + mc(2, "b", "X", "2000/01/01", /*amount=*/2), + mc(3, "c", "X", "2000/01/01", /*amount=*/4), + }; + sortMagicCards(v, MagicSortColumn::Amount, /*ascending=*/true); + CHECK(ids(v) == std::vector{2, 3, 1}); // 2 < 4 < 10 + } + + TEST_CASE("boolean flag column orders false < true (asc puts unset first)") { + std::vector v = { + mc(1, "a", "X", "2000/01/01", 1, /*foil=*/true), + mc(2, "b", "X", "2000/01/01", 1, /*foil=*/false), + mc(3, "c", "X", "2000/01/01", 1, /*foil=*/true), + mc(4, "d", "X", "2000/01/01", 1, /*foil=*/false), + }; + sortMagicCards(v, MagicSortColumn::Foil, /*ascending=*/true); + // Stable: relative order within each bucket preserved. + CHECK(ids(v) == std::vector{2, 4, 1, 3}); + + sortMagicCards(v, MagicSortColumn::Foil, /*ascending=*/false); + CHECK(ids(v) == std::vector{1, 3, 2, 4}); + } + + TEST_CASE("Signed and Altered booleans sort independently") { + std::vector v = { + mc(1, "a", "X", "2000/01/01", 1, false, /*sgnd=*/false, /*alt=*/true), + mc(2, "b", "X", "2000/01/01", 1, false, /*sgnd=*/true, /*alt=*/false), + }; + sortMagicCards(v, MagicSortColumn::Signed, /*ascending=*/true); + CHECK(ids(v) == std::vector{1, 2}); + + sortMagicCards(v, MagicSortColumn::Altered, /*ascending=*/true); + CHECK(ids(v) == std::vector{2, 1}); + } + + TEST_CASE("Language and Condition sort by their string label, lowercased") { + std::vector v = { + mc(1, "a", "X", "2000/01/01", 1, false, false, false, + Language::Japanese, Condition::Mint), + mc(2, "b", "X", "2000/01/01", 1, false, false, false, + Language::English, Condition::Played), + mc(3, "c", "X", "2000/01/01", 1, false, false, false, + Language::German, Condition::NearMint), + }; + sortMagicCards(v, MagicSortColumn::Language, /*ascending=*/true); + // english < german < japanese (lowercased compare) + CHECK(ids(v) == std::vector{2, 3, 1}); + + sortMagicCards(v, MagicSortColumn::Condition, /*ascending=*/true); + // mint < nearmint < played (lowercased compare) + CHECK(ids(v) == std::vector{1, 3, 2}); + } + + TEST_CASE("Note sorts case-insensitively") { + std::vector v = { + mc(1, "a", "X", "2000/01/01", 1, false, false, false, + Language::English, Condition::NearMint, "Zeta"), + mc(2, "b", "X", "2000/01/01", 1, false, false, false, + Language::English, Condition::NearMint, "alpha"), + mc(3, "c", "X", "2000/01/01", 1, false, false, false, + Language::English, Condition::NearMint, "Beta"), + }; + sortMagicCards(v, MagicSortColumn::Note, /*ascending=*/true); + CHECK(ids(v) == std::vector{2, 3, 1}); // alpha, beta, zeta + } + + TEST_CASE("stable: sort by name then by set keeps name order within each set") { + // Mirrors the UX expectation: a user clicks Name, then Set, and + // sees rows grouped by set with names alphabetical inside each group. + std::vector v = { + mc(1, "Counterspell", "Beta", "1993/10/04"), + mc(2, "Brainstorm", "Alpha", "1993/08/05"), + mc(3, "Lightning Bolt","Beta", "1993/10/04"), + mc(4, "Ancestral Recall","Alpha","1993/08/05"), + }; + sortMagicCards(v, MagicSortColumn::Name, /*asc=*/true); + sortMagicCards(v, MagicSortColumn::SetReleaseDate, /*asc=*/true); + // Alpha (1993/08/05) first: ancestral, brainstorm + // Beta (1993/10/04) next: counterspell, lightning bolt + CHECK(ids(v) == std::vector{4, 2, 1, 3}); + } +} + +TEST_SUITE("CardSorter - Pokemon-specific columns") { + TEST_CASE("Holo and FirstEdition each sort their own bool field") { + std::vector v = { + pc(1, "a", "X", "2000/01/01", 1, /*holo=*/true, /*1st=*/false), + pc(2, "b", "X", "2000/01/01", 1, /*holo=*/false, /*1st=*/true), + pc(3, "c", "X", "2000/01/01", 1, /*holo=*/false, /*1st=*/false), + }; + sortPokemonCards(v, PokemonSortColumn::Holo, /*ascending=*/true); + CHECK(ids(v) == std::vector{2, 3, 1}); + + sortPokemonCards(v, PokemonSortColumn::FirstEdition, /*ascending=*/true); + // After previous sort: 2,3,1 (false=2, false=1 actually... let me think + // -- with v in order {2,3,1} their firstEdition = {true,false,false}. + // Stable asc on firstEdition keeps 3 before 1 in the false bucket.) + CHECK(ids(v) == std::vector{3, 1, 2}); + } + + TEST_CASE("Set column sorts by release date for Pokemon too") { + std::vector v = { + pc(1, "x", "Sun & Moon", "2017/02/03"), + pc(2, "y", "Base Set", "1999/01/09"), + pc(3, "z", "Sword & Shield","2020/02/07"), + }; + sortPokemonCards(v, PokemonSortColumn::SetReleaseDate, /*ascending=*/true); + CHECK(ids(v) == std::vector{2, 1, 3}); + } + + TEST_CASE("Amount sorts numerically") { + std::vector v = { + pc(1, "a", "X", "2000/01/01", 9), + pc(2, "b", "X", "2000/01/01", 11), + pc(3, "c", "X", "2000/01/01", 1), + }; + sortPokemonCards(v, PokemonSortColumn::Amount, /*ascending=*/true); + CHECK(ids(v) == std::vector{3, 1, 2}); + } +} + +TEST_SUITE("CardSorter - empty / single-element inputs are no-ops") { + TEST_CASE("empty vector stays empty") { + std::vector v; + sortMagicCards(v, MagicSortColumn::Name, true); + CHECK(v.empty()); + } + + TEST_CASE("single element preserved") { + std::vector v = { mc(42, "Solo", "X", "2000/01/01") }; + sortMagicCards(v, MagicSortColumn::Amount, false); + CHECK(v.size() == 1); + CHECK(v.front().id == 42); + } +} diff --git a/tests/collection_service_tests.cpp b/tests/collection_service_tests.cpp new file mode 100644 index 0000000..276d73f --- /dev/null +++ b/tests/collection_service_tests.cpp @@ -0,0 +1,120 @@ +#include + +#include "ccm/domain/MagicCard.hpp" +#include "ccm/ports/ICollectionRepository.hpp" +#include "ccm/ports/IImageStore.hpp" +#include "ccm/services/CollectionService.hpp" + +#include +#include +#include + +using namespace ccm; + +namespace { + +class InMemoryRepo final : public ICollectionRepository { +public: + Map storage; + + Result load(Game) override { return Result::ok(storage); } + Result save(Game, const Map& m) override { + storage = m; + return Result::ok(); + } +}; + +class StubImageStore final : public IImageStore { +public: + std::vector> removed; + + Result copyIn(Game, const std::filesystem::path&, const std::string& n) override { + return Result::ok(n); + } + Result remove(Game g, const std::string& n) override { + removed.emplace_back(g, n); + return Result::ok(); + } + std::filesystem::path resolvePath(Game, const std::string& n) const override { + return std::filesystem::path(n); + } +}; + +MagicCard makeCard(const std::string& name, std::vector imgs = {}) { + MagicCard c; + c.name = name; + c.set = Set{"alp", "Alpha", "1993/08/05"}; + c.images = std::move(imgs); + return c; +} + +} // namespace + +TEST_SUITE("CollectionService") { + TEST_CASE("nextId on empty map is 0, then strictly increments") { + InMemoryRepo repo; + StubImageStore store; + CollectionService svc{repo, store}; + + const auto id0 = svc.add(Game::Magic, makeCard("A")); + REQUIRE(id0.isOk()); + CHECK(id0.value() == 0); + + const auto id1 = svc.add(Game::Magic, makeCard("B")); + REQUIRE(id1.isOk()); + CHECK(id1.value() == 1); + + const auto listed = svc.list(Game::Magic); + REQUIRE(listed.isOk()); + CHECK(listed.value().size() == 2); + } + + TEST_CASE("update modifies an existing card and is rejected for unknown ids") { + InMemoryRepo repo; + StubImageStore store; + CollectionService svc{repo, store}; + + const auto id = svc.add(Game::Magic, makeCard("Initial")).value(); + + MagicCard updated = makeCard("Renamed"); + updated.id = id; + CHECK(svc.update(Game::Magic, updated).isOk()); + + const auto found = svc.findById(Game::Magic, id); + REQUIRE(found.isOk()); + REQUIRE(found.value().has_value()); + CHECK(found.value()->name == "Renamed"); + + MagicCard ghost = makeCard("Ghost"); + ghost.id = 999; + CHECK(svc.update(Game::Magic, ghost).isErr()); + } + + TEST_CASE("remove deletes images and the entry") { + InMemoryRepo repo; + StubImageStore store; + CollectionService svc{repo, store}; + + const auto id = svc.add( + Game::Magic, makeCard("With Images", {"a.png", "b.png"})).value(); + + REQUIRE(svc.remove(Game::Magic, id).isOk()); + + // Both images should have been requested for deletion. + REQUIRE(store.removed.size() == 2); + CHECK(store.removed[0].second == "a.png"); + CHECK(store.removed[1].second == "b.png"); + + const auto listed = svc.list(Game::Magic); + REQUIRE(listed.isOk()); + CHECK(listed.value().empty()); + } + + TEST_CASE("remove of unknown id returns an error") { + InMemoryRepo repo; + StubImageStore store; + CollectionService svc{repo, store}; + + CHECK(svc.remove(Game::Magic, 12345).isErr()); + } +} diff --git a/tests/config_service_tests.cpp b/tests/config_service_tests.cpp new file mode 100644 index 0000000..85b1fa3 --- /dev/null +++ b/tests/config_service_tests.cpp @@ -0,0 +1,78 @@ +#include + +#include "ccm/services/ConfigService.hpp" + +#include "fakes/InMemoryFileSystem.hpp" + +#include + +using namespace ccm; +using ccm::testing::InMemoryFileSystem; + +TEST_SUITE("ConfigService") { + TEST_CASE("missing file is created with defaults") { + InMemoryFileSystem fs; + ConfigService svc{fs, "/app/config.json", "/data"}; + + REQUIRE(svc.initialize().isOk()); + CHECK(svc.current().dataStorage == "/data"); + CHECK(svc.current().defaultGame == Game::Magic); + CHECK(svc.current().theme == Theme::Light); + + const auto& written = fs.files(); + REQUIRE(written.count("/app/config.json")); + const auto j = nlohmann::json::parse(written.at("/app/config.json")); + CHECK(j.at("dataStorage") == "/data"); + CHECK(j.at("defaultGame") == "Magic"); + CHECK(j.at("theme") == "Light"); + } + + TEST_CASE("existing file is loaded verbatim") { + InMemoryFileSystem fs; + REQUIRE(fs.writeText("/app/config.json", + R"({"dataStorage":"/somewhere","defaultGame":"Pokemon","theme":"Dark"})").isOk()); + + ConfigService svc{fs, "/app/config.json", "/default"}; + REQUIRE(svc.initialize().isOk()); + CHECK(svc.current().dataStorage == "/somewhere"); + CHECK(svc.current().defaultGame == Game::Pokemon); + CHECK(svc.current().theme == Theme::Dark); + } + + TEST_CASE("store updates both the live config and the file") { + InMemoryFileSystem fs; + ConfigService svc{fs, "/app/config.json", "/data"}; + REQUIRE(svc.initialize().isOk()); + + Configuration next; + next.dataStorage = "/new/place"; + next.defaultGame = Game::Pokemon; + next.theme = Theme::Dark; + REQUIRE(svc.store(next).isOk()); + + CHECK(svc.current() == next); + const auto j = nlohmann::json::parse(fs.files().at("/app/config.json")); + CHECK(j.at("dataStorage") == "/new/place"); + CHECK(j.at("defaultGame") == "Pokemon"); + CHECK(j.at("theme") == "Dark"); + } + + TEST_CASE("missing theme field defaults to light for compatibility") { + InMemoryFileSystem fs; + REQUIRE(fs.writeText("/app/config.json", + R"({"dataStorage":"/somewhere","defaultGame":"Pokemon"})").isOk()); + + ConfigService svc{fs, "/app/config.json", "/default"}; + REQUIRE(svc.initialize().isOk()); + CHECK(svc.current().theme == Theme::Light); + } + + TEST_CASE("malformed JSON surfaces a clear error") { + InMemoryFileSystem fs; + REQUIRE(fs.writeText("/app/config.json", "not-json").isOk()); + ConfigService svc{fs, "/app/config.json", "/default"}; + const auto r = svc.initialize(); + REQUIRE(r.isErr()); + CHECK(r.error().find("config.json parse error") != std::string::npos); + } +} diff --git a/tests/domain_json_tests.cpp b/tests/domain_json_tests.cpp new file mode 100644 index 0000000..6a72031 --- /dev/null +++ b/tests/domain_json_tests.cpp @@ -0,0 +1,123 @@ +#include + +#include "ccm/domain/Configuration.hpp" +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/MagicCard.hpp" +#include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/Set.hpp" + +#include + +using namespace ccm; + +TEST_SUITE("domain enums round-trip JSON as strings") { + TEST_CASE("Game") { + nlohmann::json j = Game::Magic; + CHECK(j.get() == "Magic"); + CHECK(j.get() == Game::Magic); + + nlohmann::json j2 = "Pokemon"; + CHECK(j2.get() == Game::Pokemon); + + nlohmann::json j3 = Theme::Dark; + CHECK(j3.get() == "Dark"); + CHECK(j3.get() == Theme::Dark); + } + + TEST_CASE("Language and Condition") { + nlohmann::json l = Language::Japanese; + CHECK(l.get() == "Japanese"); + CHECK(l.get() == Language::Japanese); + + nlohmann::json c = Condition::LightPlayed; + CHECK(c.get() == "LightPlayed"); + CHECK(c.get() == Condition::LightPlayed); + } + + TEST_CASE("invalid enum string throws") { + nlohmann::json bad = "Spanglish"; + CHECK_THROWS(bad.get()); + } +} + +TEST_SUITE("Set JSON shape stays stable") { + TEST_CASE("uses 'releaseDate' alias") { + Set s{"abc", "Test Set", "2024/05/01"}; + const nlohmann::json j = s; + CHECK(j.contains("releaseDate")); + CHECK_FALSE(j.contains("release_date")); + CHECK(j.at("releaseDate") == "2024/05/01"); + + const Set back = j.get(); + CHECK(back == s); + } +} + +TEST_SUITE("MagicCard JSON") { + TEST_CASE("round-trips with all original field names") { + MagicCard c; + c.id = 42; + c.amount = 3; + c.name = "Lightning Bolt"; + c.set = Set{"lea", "Limited Edition Alpha", "1993/08/05"}; + c.note = "rare promo"; + c.images = {"foo+bar+0.png"}; + c.language = Language::English; + c.condition = Condition::NearMint; + c.foil = true; + c.signed_ = false; + c.altered = false; + + nlohmann::json j = c; + CHECK(j.contains("signed")); + CHECK(j.at("signed") == false); + CHECK(j.at("foil") == true); + + const MagicCard back = j.get(); + CHECK(back == c); + } +} + +TEST_SUITE("PokemonCard JSON") { + TEST_CASE("uses 'setNo' and 'firstEdition' aliases") { + PokemonCard c; + c.id = 7; + c.amount = 1; + c.name = "Charizard"; + c.set = Set{"base1", "Base Set", "1999/01/09"}; + c.setNo = "4/102"; + c.note = ""; + c.images = {}; + c.language = Language::English; + c.condition = Condition::Excellent; + c.firstEdition = true; + c.holo = true; + c.signed_ = false; + c.altered = false; + + nlohmann::json j = c; + CHECK(j.at("setNo") == "4/102"); + CHECK(j.at("firstEdition") == true); + CHECK(j.at("signed") == false); + + const PokemonCard back = j.get(); + CHECK(back == c); + } +} + +TEST_SUITE("Configuration JSON matches Rust serde aliases") { + TEST_CASE("dataStorage / defaultGame / theme keys are present") { + Configuration cfg; + cfg.dataStorage = "/some/path"; + cfg.defaultGame = Game::Pokemon; + cfg.theme = Theme::Dark; + + nlohmann::json j = cfg; + CHECK(j.at("dataStorage") == "/some/path"); + CHECK(j.at("defaultGame") == "Pokemon"); + CHECK(j.at("theme") == "Dark"); + + const auto back = j.get(); + CHECK(back == cfg); + } +} diff --git a/tests/fakes/InMemoryFileSystem.cpp b/tests/fakes/InMemoryFileSystem.cpp new file mode 100644 index 0000000..a5ad1fc --- /dev/null +++ b/tests/fakes/InMemoryFileSystem.cpp @@ -0,0 +1,85 @@ +#include "fakes/InMemoryFileSystem.hpp" + +namespace ccm::testing { + +namespace fs = std::filesystem; + +std::string InMemoryFileSystem::norm(const fs::path& p) { + // Use forward slashes regardless of host so tests are portable. + return p.lexically_normal().generic_string(); +} + +bool InMemoryFileSystem::exists(const fs::path& p) const { + const auto k = norm(p); + return files_.count(k) || dirs_.count(k); +} + +bool InMemoryFileSystem::isDirectory(const fs::path& p) const { + return dirs_.count(norm(p)) > 0; +} + +Result InMemoryFileSystem::ensureDirectory(const fs::path& p) { + auto k = norm(p); + if (files_.count(k)) { + return Result::err("Path is a file, cannot become a directory: " + k); + } + // Walk parent chain so listDirectory() acts naturally. + fs::path acc; + for (const auto& part : p) { + acc /= part; + dirs_.insert(norm(acc)); + } + return Result::ok(); +} + +Result InMemoryFileSystem::readText(const fs::path& p) { + auto it = files_.find(norm(p)); + if (it == files_.end()) { + return Result::err("Not found: " + norm(p)); + } + return Result::ok(it->second); +} + +Result InMemoryFileSystem::writeText(const fs::path& p, std::string_view contents) { + if (p.has_parent_path()) { + auto r = ensureDirectory(p.parent_path()); + if (!r) return r; + } + files_[norm(p)] = std::string(contents); + return Result::ok(); +} + +Result InMemoryFileSystem::copyFile(const fs::path& from, const fs::path& to, bool overwrite) { + auto src = files_.find(norm(from)); + if (src == files_.end()) { + return Result::err("copy_file source missing: " + norm(from)); + } + if (!overwrite && files_.count(norm(to))) { + return Result::err("copy_file destination exists: " + norm(to)); + } + if (to.has_parent_path()) { + auto r = ensureDirectory(to.parent_path()); + if (!r) return r; + } + files_[norm(to)] = src->second; + return Result::ok(); +} + +Result InMemoryFileSystem::remove(const fs::path& p) { + files_.erase(norm(p)); + return Result::ok(); +} + +Result> InMemoryFileSystem::listDirectory(const fs::path& p) { + const auto prefix = norm(p) + "/"; + std::vector out; + for (const auto& [path, _] : files_) { + if (path.rfind(prefix, 0) == 0 && + path.find('/', prefix.size()) == std::string::npos) { + out.emplace_back(path); + } + } + return Result>::ok(std::move(out)); +} + +} // namespace ccm::testing diff --git a/tests/fakes/InMemoryFileSystem.hpp b/tests/fakes/InMemoryFileSystem.hpp new file mode 100644 index 0000000..ca599d9 --- /dev/null +++ b/tests/fakes/InMemoryFileSystem.hpp @@ -0,0 +1,40 @@ +#pragma once + +// In-memory IFileSystem fake used to drive the service tests with no real I/O. +// Tracks regular files (string contents) and directories (just by presence). + +#include "ccm/ports/IFileSystem.hpp" + +#include +#include +#include + +namespace ccm::testing { + +class InMemoryFileSystem final : public IFileSystem { +public: + [[nodiscard]] bool exists(const std::filesystem::path& p) const override; + [[nodiscard]] bool isDirectory(const std::filesystem::path& p) const override; + + Result ensureDirectory(const std::filesystem::path& p) override; + Result readText(const std::filesystem::path& p) override; + Result writeText(const std::filesystem::path& p, std::string_view contents) override; + Result copyFile(const std::filesystem::path& from, + const std::filesystem::path& to, + bool overwrite) override; + Result remove(const std::filesystem::path& p) override; + Result> listDirectory( + const std::filesystem::path& p) override; + + // Test helpers. + [[nodiscard]] const std::map& files() const noexcept { return files_; } + [[nodiscard]] const std::set& dirs() const noexcept { return dirs_; } + +private: + static std::string norm(const std::filesystem::path& p); + + std::map files_; + std::set dirs_; +}; + +} // namespace ccm::testing diff --git a/tests/fs_names_tests.cpp b/tests/fs_names_tests.cpp new file mode 100644 index 0000000..921944b --- /dev/null +++ b/tests/fs_names_tests.cpp @@ -0,0 +1,53 @@ +#include + +#include "ccm/util/FsNames.hpp" + +using ccm::formatTextForFs; +using ccm::parseIndexFromFilename; + +TEST_SUITE("FsNames::formatTextForFs") { + TEST_CASE("strips spaces, commas, apostrophes, backticks") { + CHECK(formatTextForFs("Hello, World's Set") == "HelloWorldsSet"); + CHECK(formatTextForFs("`tick`") == "tick"); + } + + TEST_CASE("colons become hyphens") { + CHECK(formatTextForFs("Set: Subtitle") == "Set-Subtitle"); + } + + TEST_CASE("ampersand becomes And, pipe becomes Or") { + CHECK(formatTextForFs("Black & White") == "BlackAndWhite"); + CHECK(formatTextForFs("a|b") == "aOrb"); + } + + TEST_CASE("flattens accented vowels listed in the original Rust source") { + // UTF-8 sequences for the accented characters the Rust crate handles. + CHECK(formatTextForFs("\xC3\xA1\xC3\xA9\xC3\xAD\xC3\xB3\xC3\xBA\xC3\xBB") == "aeiouu"); + } + + TEST_CASE("idempotent on already-clean strings") { + CHECK(formatTextForFs("AlreadyClean") == "AlreadyClean"); + } +} + +TEST_SUITE("FsNames::parseIndexFromFilename") { + TEST_CASE("single digit") { + CHECK(parseIndexFromFilename("Image1.png") == 1); + CHECK(parseIndexFromFilename("foo+bar+0.jpg") == 0); + } + + TEST_CASE("two digit") { + CHECK(parseIndexFromFilename("Image22.jpeg") == 22); + CHECK(parseIndexFromFilename("set+name+99.png") == 99); + } + + TEST_CASE("only the last two digits are taken (matches Rust source)") { + CHECK(parseIndexFromFilename("foo+123+45.png") == 45); + } + + TEST_CASE("no digits or no extension returns 0") { + CHECK(parseIndexFromFilename("noindex.png") == 0); + CHECK(parseIndexFromFilename("nothing") == 0); + CHECK(parseIndexFromFilename("") == 0); + } +} diff --git a/tests/image_service_tests.cpp b/tests/image_service_tests.cpp new file mode 100644 index 0000000..68e7b48 --- /dev/null +++ b/tests/image_service_tests.cpp @@ -0,0 +1,131 @@ +#include + +#include "ccm/domain/Enums.hpp" +#include "ccm/ports/IImageStore.hpp" +#include "ccm/services/ImageService.hpp" + +#include +#include +#include + +using namespace ccm; + +namespace { + +// Trivial image store that records calls without touching disk. +class RecordingImageStore final : public IImageStore { +public: + struct Call { Game game; std::filesystem::path src; std::string target; }; + std::vector copies; + std::vector> removes; + std::string returnedExt = ".png"; + + Result copyIn(Game game, + const std::filesystem::path& srcPath, + const std::string& targetName) override { + copies.push_back({game, srcPath, targetName}); + return Result::ok(targetName + returnedExt); + } + + Result remove(Game game, const std::string& imageName) override { + removes.emplace_back(game, imageName); + return Result::ok(); + } + + std::filesystem::path resolvePath(Game, const std::string& imageName) const override { + return std::filesystem::path("/fake") / imageName; + } +}; + +} // namespace + +TEST_SUITE("ImageService::nextImageIndex") { + TEST_CASE("empty list returns 0") { + CHECK(ImageService::nextImageIndex({}) == 0); + } + + TEST_CASE("increments by one over the latest filename") { + std::vector imgs = {"set+name+0.png", "set+name+1.png"}; + CHECK(ImageService::nextImageIndex(imgs) == 2); + } + + TEST_CASE("CCM1-style filenames reset back to 0") { + std::vector imgs = {"someCardIMG_FRONT.png"}; + CHECK(ImageService::nextImageIndex(imgs) == 0); + std::vector imgs2 = {"otherIMG_BACK.png"}; + CHECK(ImageService::nextImageIndex(imgs2) == 0); + } +} + +TEST_SUITE("ImageService::buildTargetName") { + TEST_CASE("new entry omits id, uses sanitized set+name+idx") { + const auto out = ImageService::buildTargetName(true, 99, "Limited: Alpha", "Lightning, Bolt", 3); + CHECK(out == "Limited-Alpha+LightningBolt+3"); + } + + TEST_CASE("existing entry prepends the card id") { + const auto out = ImageService::buildTargetName(false, 42, "Beta", "Black Lotus", 0); + CHECK(out == "42+Beta+BlackLotus+0"); + } +} + +TEST_SUITE("ImageService::addImage") { + TEST_CASE("delegates to the store with the computed target name") { + RecordingImageStore store; + ImageService svc{store}; + + std::vector existing; + auto res = svc.addImage(Game::Magic, "/tmp/source.png", + /*newEntry=*/true, /*cardId=*/0, + "Beta", "Black Lotus", existing); + + REQUIRE(res.isOk()); + CHECK(res.value() == "Beta+BlackLotus+0.png"); + REQUIRE(store.copies.size() == 1); + CHECK(store.copies[0].game == Game::Magic); + CHECK(store.copies[0].target == "Beta+BlackLotus+0"); + } +} + +TEST_SUITE("ImageService::normalizeNamesForPersistedCard") { + TEST_CASE("renames non-prefixed images to include card id") { + RecordingImageStore store; + ImageService svc{store}; + + const std::vector images{ + "Beta+BlackLotus+0.png", + "Beta+BlackLotus+1.jpg" + }; + auto normalized = svc.normalizeNamesForPersistedCard( + Game::Magic, 42, "Beta", "Black Lotus", images); + + REQUIRE(normalized.isOk()); + CHECK(normalized.value().size() == 2); + CHECK(normalized.value()[0] == "42+Beta+BlackLotus+0.png"); + CHECK(normalized.value()[1] == "42+Beta+BlackLotus+1.jpg"); + + REQUIRE(store.copies.size() == 2); + CHECK(store.copies[0].src == std::filesystem::path("/fake/Beta+BlackLotus+0.png")); + CHECK(store.copies[0].target == "42+Beta+BlackLotus+0"); + CHECK(store.copies[1].src == std::filesystem::path("/fake/Beta+BlackLotus+1.jpg")); + CHECK(store.copies[1].target == "42+Beta+BlackLotus+1"); + + REQUIRE(store.removes.size() == 2); + CHECK(store.removes[0].second == "Beta+BlackLotus+0.png"); + CHECK(store.removes[1].second == "Beta+BlackLotus+1.jpg"); + } + + TEST_CASE("keeps already-prefixed names untouched") { + RecordingImageStore store; + ImageService svc{store}; + + const std::vector images{"42+Beta+BlackLotus+0.png"}; + auto normalized = svc.normalizeNamesForPersistedCard( + Game::Magic, 42, "Beta", "Black Lotus", images); + + REQUIRE(normalized.isOk()); + CHECK(normalized.value() == images); + CHECK(store.copies.empty()); + CHECK(store.removes.empty()); + } +} diff --git a/tests/json_collection_repository_tests.cpp b/tests/json_collection_repository_tests.cpp new file mode 100644 index 0000000..580401c --- /dev/null +++ b/tests/json_collection_repository_tests.cpp @@ -0,0 +1,88 @@ +#include + +#include "ccm/domain/MagicCard.hpp" +#include "ccm/infra/JsonCollectionRepository.hpp" +#include "ccm/services/ConfigService.hpp" + +#include "fakes/InMemoryFileSystem.hpp" + +#include + +using namespace ccm; +using ccm::testing::InMemoryFileSystem; + +namespace { + +ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) { + // Pre-write a config so initialize() doesn't reset our data directory. + Configuration c; + c.dataStorage = dataDir; + c.defaultGame = Game::Magic; + const nlohmann::json j = c; + fs.writeText("/app/config.json", j.dump()); + ConfigService cfg{fs, "/app/config.json", dataDir}; + cfg.initialize(); + return cfg; +} + +std::string magicDir(Game g) { return g == Game::Magic ? "magic" : "pokemon"; } + +} // namespace + +TEST_SUITE("JsonCollectionRepository") { + TEST_CASE("missing collection.json is created on first load") { + InMemoryFileSystem fs; + auto cfg = makeConfig(fs, "/data"); + JsonCollectionRepository repo{fs, cfg, magicDir}; + + const auto loaded = repo.load(Game::Magic); + REQUIRE(loaded.isOk()); + CHECK(loaded.value().empty()); + CHECK(fs.files().count("/data/magic/collection.json") == 1); + } + + TEST_CASE("save/load round-trip preserves all card fields") { + InMemoryFileSystem fs; + auto cfg = makeConfig(fs, "/data"); + JsonCollectionRepository repo{fs, cfg, magicDir}; + + MagicCard c; + c.id = 5; + c.amount = 2; + c.name = "Black Lotus"; + c.set = Set{"lea", "Limited Edition Alpha", "1993/08/05"}; + c.note = "tournament-illegal"; + c.images = {"lea+BlackLotus+0.png"}; + c.language = Language::English; + c.condition = Condition::Mint; + c.foil = false; + c.signed_ = true; + c.altered = false; + + std::map m{{c.id, c}}; + REQUIRE(repo.save(Game::Magic, m).isOk()); + + const auto loaded = repo.load(Game::Magic); + REQUIRE(loaded.isOk()); + REQUIRE(loaded.value().count(5) == 1); + CHECK(loaded.value().at(5) == c); + } + + TEST_CASE("on-disk JSON is keyed by stringified card id") { + InMemoryFileSystem fs; + auto cfg = makeConfig(fs, "/data"); + JsonCollectionRepository repo{fs, cfg, magicDir}; + + MagicCard c; + c.id = 17; + c.name = "X"; + c.set = Set{"x", "X", "2024/01/01"}; + std::map m{{c.id, c}}; + REQUIRE(repo.save(Game::Magic, m).isOk()); + + const auto& contents = fs.files().at("/data/magic/collection.json"); + const auto j = nlohmann::json::parse(contents); + REQUIRE(j.contains("17")); + CHECK(j.at("17").at("id") == 17); + } +} diff --git a/tests/json_set_repository_tests.cpp b/tests/json_set_repository_tests.cpp new file mode 100644 index 0000000..d23e35c --- /dev/null +++ b/tests/json_set_repository_tests.cpp @@ -0,0 +1,52 @@ +#include + +#include "ccm/infra/JsonSetRepository.hpp" +#include "ccm/services/ConfigService.hpp" + +#include "fakes/InMemoryFileSystem.hpp" + +#include + +using namespace ccm; +using ccm::testing::InMemoryFileSystem; + +namespace { +std::string dirNameFn(Game g) { return g == Game::Magic ? "magic" : "pokemon"; } + +ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) { + Configuration c; + c.dataStorage = dataDir; + c.defaultGame = Game::Magic; + fs.writeText("/app/config.json", nlohmann::json(c).dump()); + ConfigService cfg{fs, "/app/config.json", dataDir}; + cfg.initialize(); + return cfg; +} +} // namespace + +TEST_SUITE("JsonSetRepository") { + TEST_CASE("save then load returns identical sets") { + InMemoryFileSystem fs; + auto cfg = makeConfig(fs, "/data"); + JsonSetRepository repo{fs, cfg, dirNameFn}; + + const std::vector sets = { + {"lea", "Limited Edition Alpha", "1993/08/05"}, + {"leb", "Limited Edition Beta", "1993/10/04"}, + }; + REQUIRE(repo.save(Game::Magic, sets).isOk()); + + const auto loaded = repo.load(Game::Magic); + REQUIRE(loaded.isOk()); + CHECK(loaded.value() == sets); + } + + TEST_CASE("load before any save reports a clear error") { + InMemoryFileSystem fs; + auto cfg = makeConfig(fs, "/data"); + JsonSetRepository repo{fs, cfg, dirNameFn}; + + const auto loaded = repo.load(Game::Pokemon); + CHECK(loaded.isErr()); + } +} diff --git a/tests/magic_card_preview_source_tests.cpp b/tests/magic_card_preview_source_tests.cpp new file mode 100644 index 0000000..ccc1453 --- /dev/null +++ b/tests/magic_card_preview_source_tests.cpp @@ -0,0 +1,112 @@ +#include + +#include "ccm/games/magic/MagicCardPreviewSource.hpp" +#include "ccm/ports/IHttpClient.hpp" + +#include + +using namespace ccm; + +namespace { + +class FixedHttpClient final : public IHttpClient { +public: + std::string lastUrl; + std::string body; + bool ok = true; + Result get(std::string_view url) override { + lastUrl = std::string(url); + return ok ? Result::ok(body) + : Result::err("offline"); + } +}; + +} // namespace + +TEST_SUITE("MagicCardPreviewSource::buildSearchUrl") { + TEST_CASE("simple name and set produce a percent-encoded query") { + const auto url = MagicCardPreviewSource::buildSearchUrl("Lightning Bolt", "lea"); + // Spaces -> %20, quotes -> %22, colons -> %3A. setId stays as-is when + // it only contains unreserved chars. + CHECK(url.find("https://api.scryfall.com/cards/search?q=") == 0); + CHECK(url.find("%22Lightning%20Bolt%22") != std::string::npos); + CHECK(url.find("set%3Alea") != std::string::npos); + } + + TEST_CASE("ampersand in card name is replaced with 'and' before encoding") { + const auto url = MagicCardPreviewSource::buildSearchUrl("Fire & Ice", "abc"); + CHECK(url.find("Fire%20and%20Ice") != std::string::npos); + CHECK(url.find("%26") == std::string::npos); + } + + TEST_CASE("unreserved characters in setId are preserved") { + const auto url = MagicCardPreviewSource::buildSearchUrl("X", "swsh10"); + CHECK(url.find("set%3Aswsh10") != std::string::npos); + } +} + +TEST_SUITE("MagicCardPreviewSource::parseResponse") { + TEST_CASE("happy path returns image_uris.normal") { + const std::string json = R"({ + "data": [ + { + "name": "Lightning Bolt", + "image_uris": { + "small": "https://img.scryfall.io/small.jpg", + "normal": "https://img.scryfall.io/normal.jpg", + "large": "https://img.scryfall.io/large.jpg" + } + } + ] + })"; + const auto out = MagicCardPreviewSource::parseResponse(json); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://img.scryfall.io/normal.jpg"); + } + + TEST_CASE("empty data array returns an error") { + const auto out = MagicCardPreviewSource::parseResponse(R"({"data":[]})"); + CHECK(out.isErr()); + } + + TEST_CASE("missing data array returns an error") { + const auto out = MagicCardPreviewSource::parseResponse(R"({"meta":{}})"); + CHECK(out.isErr()); + } + + TEST_CASE("entry without image_uris returns an error (double-faced cards)") { + const std::string json = R"({ + "data": [ + {"name":"DoubleFace","card_faces":[{"image_uris":{"normal":"x"}}]} + ] + })"; + const auto out = MagicCardPreviewSource::parseResponse(json); + CHECK(out.isErr()); + } + + TEST_CASE("invalid JSON returns an error") { + const auto out = MagicCardPreviewSource::parseResponse("{not json"); + CHECK(out.isErr()); + } +} + +TEST_SUITE("MagicCardPreviewSource::fetchImageUrl") { + TEST_CASE("network error is surfaced as a Result error") { + FixedHttpClient http; + http.ok = false; + MagicCardPreviewSource src{http}; + CHECK(src.fetchImageUrl("Lightning Bolt", "lea", "").isErr()); + } + + TEST_CASE("network success is parsed end-to-end and uses the encoded URL") { + FixedHttpClient http; + http.ok = true; + http.body = R"({"data":[{"image_uris":{"normal":"https://img/normal.jpg"}}]})"; + MagicCardPreviewSource src{http}; + const auto out = src.fetchImageUrl("Lightning Bolt", "lea", ""); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://img/normal.jpg"); + CHECK(http.lastUrl.find("%22Lightning%20Bolt%22") != std::string::npos); + CHECK(http.lastUrl.find("set%3Alea") != std::string::npos); + } +} diff --git a/tests/magic_set_source_tests.cpp b/tests/magic_set_source_tests.cpp new file mode 100644 index 0000000..deb1cb0 --- /dev/null +++ b/tests/magic_set_source_tests.cpp @@ -0,0 +1,83 @@ +#include + +#include "ccm/games/magic/MagicSetSource.hpp" +#include "ccm/ports/IHttpClient.hpp" + +using namespace ccm; + +namespace { + +class FixedHttpClient final : public IHttpClient { +public: + std::string body; + bool ok = true; + Result get(std::string_view) override { + return ok ? Result::ok(body) + : Result::err("offline"); + } +}; + +} // namespace + +TEST_SUITE("MagicSetSource::parseResponse") { + TEST_CASE("filters digital sets and converts release date format") { + const std::string json = R"({ + "data": [ + {"code":"lea","name":"Alpha","released_at":"1993-08-05","digital":false}, + {"code":"mtgo","name":"Online Promo","released_at":"2010-01-01","digital":true}, + {"code":"leb","name":"Beta","released_at":"1993-10-04","digital":false} + ] + })"; + + const auto out = MagicSetSource::parseResponse(json); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 2); + CHECK(out.value()[0].id == "lea"); + CHECK(out.value()[0].releaseDate == "1993/08/05"); + CHECK(out.value()[1].id == "leb"); + CHECK(out.value()[1].releaseDate == "1993/10/04"); + } + + TEST_CASE("sorts by release date ascending") { + const std::string json = R"({ + "data": [ + {"code":"newer","name":"N","released_at":"2024-01-01","digital":false}, + {"code":"older","name":"O","released_at":"2010-01-01","digital":false} + ] + })"; + const auto out = MagicSetSource::parseResponse(json); + REQUIRE(out.isOk()); + CHECK(out.value().front().id == "older"); + CHECK(out.value().back().id == "newer"); + } + + TEST_CASE("missing data array returns an error") { + const auto out = MagicSetSource::parseResponse(R"({"meta":{}})"); + CHECK(out.isErr()); + } + + TEST_CASE("invalid JSON returns an error") { + const auto out = MagicSetSource::parseResponse("{not json"); + CHECK(out.isErr()); + } +} + +TEST_SUITE("MagicSetSource::fetchAll") { + TEST_CASE("network error is surfaced as a Result error") { + FixedHttpClient http; + http.ok = false; + MagicSetSource src{http}; + CHECK(src.fetchAll().isErr()); + } + + TEST_CASE("network success is parsed end-to-end") { + FixedHttpClient http; + http.ok = true; + http.body = R"({"data":[{"code":"x","name":"X","released_at":"2020-01-01","digital":false}]})"; + MagicSetSource src{http}; + const auto out = src.fetchAll(); + REQUIRE(out.isOk()); + CHECK(out.value().front().id == "x"); + CHECK(out.value().front().releaseDate == "2020/01/01"); + } +} diff --git a/tests/main.cpp b/tests/main.cpp new file mode 100644 index 0000000..0a3f254 --- /dev/null +++ b/tests/main.cpp @@ -0,0 +1,2 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include diff --git a/tests/pokemon_card_preview_source_tests.cpp b/tests/pokemon_card_preview_source_tests.cpp new file mode 100644 index 0000000..ffb6b3a --- /dev/null +++ b/tests/pokemon_card_preview_source_tests.cpp @@ -0,0 +1,130 @@ +#include + +#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp" +#include "ccm/ports/IHttpClient.hpp" + +#include + +using namespace ccm; + +namespace { + +class FixedHttpClient final : public IHttpClient { +public: + std::string lastUrl; + std::string body; + bool ok = true; + Result get(std::string_view url) override { + lastUrl = std::string(url); + return ok ? Result::ok(body) + : Result::err("offline"); + } +}; + +} // namespace + +TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") { + TEST_CASE("name and setId produce a percent-encoded query") { + const auto url = PokemonCardPreviewSource::buildSearchUrl( + "Pikachu", "base1", ""); + CHECK(url.find("https://api.pokemontcg.io/v2/cards?q=") == 0); + CHECK(url.find("%22Pikachu%22") != std::string::npos); + CHECK(url.find("set.id%3Abase1") != std::string::npos); + // No number term when setNo is empty. + CHECK(url.find("number") == std::string::npos); + } + + TEST_CASE("setNo is appended as a number: clause") { + const auto url = PokemonCardPreviewSource::buildSearchUrl( + "Charizard", "base1", "4"); + CHECK(url.find("number%3A4") != std::string::npos); + } + + TEST_CASE("setNo with a slash is normalized to the printed number") { + // Pokemon collection numbers are commonly stored as "4/102" — the + // Pokemon TCG search API only accepts the printed-number portion. + const auto url = PokemonCardPreviewSource::buildSearchUrl( + "Charizard", "base1", "4/102"); + CHECK(url.find("number%3A4") != std::string::npos); + CHECK(url.find("102") == std::string::npos); + } + + TEST_CASE("name with spaces is percent-encoded") { + const auto url = PokemonCardPreviewSource::buildSearchUrl( + "Mr. Mime", "base1", ""); + CHECK(url.find("%22Mr.%20Mime%22") != std::string::npos); + } +} + +TEST_SUITE("PokemonCardPreviewSource::parseResponse") { + TEST_CASE("returns images.large when present") { + const std::string json = R"({ + "data": [ + { + "name": "Pikachu", + "images": { + "small": "https://images.pokemontcg.io/small.png", + "large": "https://images.pokemontcg.io/large.png" + } + } + ] + })"; + const auto out = PokemonCardPreviewSource::parseResponse(json); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://images.pokemontcg.io/large.png"); + } + + TEST_CASE("falls back to images.small when large is absent") { + const std::string json = R"({ + "data": [ + {"name":"Pikachu","images":{"small":"https://small.only/img.png"}} + ] + })"; + const auto out = PokemonCardPreviewSource::parseResponse(json); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://small.only/img.png"); + } + + TEST_CASE("empty data array returns an error") { + const auto out = PokemonCardPreviewSource::parseResponse(R"({"data":[]})"); + CHECK(out.isErr()); + } + + TEST_CASE("missing data array returns an error") { + const auto out = PokemonCardPreviewSource::parseResponse(R"({"meta":{}})"); + CHECK(out.isErr()); + } + + TEST_CASE("entry without images returns an error") { + const auto out = PokemonCardPreviewSource::parseResponse( + R"({"data":[{"name":"Pikachu"}]})"); + CHECK(out.isErr()); + } + + TEST_CASE("invalid JSON returns an error") { + const auto out = PokemonCardPreviewSource::parseResponse("{not json"); + CHECK(out.isErr()); + } +} + +TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") { + TEST_CASE("network error is surfaced as a Result error") { + FixedHttpClient http; + http.ok = false; + PokemonCardPreviewSource src{http}; + CHECK(src.fetchImageUrl("Pikachu", "base1", "").isErr()); + } + + TEST_CASE("network success is parsed end-to-end and uses the encoded URL") { + FixedHttpClient http; + http.ok = true; + http.body = R"({"data":[{"images":{"large":"https://l/x.png"}}]})"; + PokemonCardPreviewSource src{http}; + const auto out = src.fetchImageUrl("Pikachu", "base1", "25"); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://l/x.png"); + CHECK(http.lastUrl.find("%22Pikachu%22") != std::string::npos); + CHECK(http.lastUrl.find("set.id%3Abase1") != std::string::npos); + CHECK(http.lastUrl.find("number%3A25") != std::string::npos); + } +} diff --git a/tests/pokemon_set_source_tests.cpp b/tests/pokemon_set_source_tests.cpp new file mode 100644 index 0000000..2af5d95 --- /dev/null +++ b/tests/pokemon_set_source_tests.cpp @@ -0,0 +1,94 @@ +#include + +#include "ccm/games/pokemon/PokemonSetSource.hpp" +#include "ccm/ports/IHttpClient.hpp" + +using namespace ccm; + +namespace { + +class FixedHttpClient final : public IHttpClient { +public: + std::string lastUrl; + std::string body; + bool ok = true; + Result get(std::string_view url) override { + lastUrl = std::string(url); + return ok ? Result::ok(body) + : Result::err("offline"); + } +}; + +} // namespace + +TEST_SUITE("PokemonSetSource::parseResponse") { + TEST_CASE("happy path: maps id/name/releaseDate without rewriting separators") { + // The Pokemon TCG API returns releaseDate already in YYYY/MM/DD form, + // unlike Scryfall's released_at YYYY-MM-DD. + const std::string json = R"({ + "data": [ + {"id":"base1","name":"Base","releaseDate":"1999/01/09"}, + {"id":"jungle","name":"Jungle","releaseDate":"1999/06/16"} + ] + })"; + + const auto out = PokemonSetSource::parseResponse(json); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 2); + CHECK(out.value()[0].id == "base1"); + CHECK(out.value()[0].name == "Base"); + CHECK(out.value()[0].releaseDate == "1999/01/09"); + CHECK(out.value()[1].id == "jungle"); + CHECK(out.value()[1].releaseDate == "1999/06/16"); + } + + TEST_CASE("sorts by release date ascending") { + const std::string json = R"({ + "data": [ + {"id":"newer","name":"N","releaseDate":"2024/01/01"}, + {"id":"older","name":"O","releaseDate":"2010/01/01"} + ] + })"; + const auto out = PokemonSetSource::parseResponse(json); + REQUIRE(out.isOk()); + CHECK(out.value().front().id == "older"); + CHECK(out.value().back().id == "newer"); + } + + TEST_CASE("empty data array returns an empty list (not an error)") { + const auto out = PokemonSetSource::parseResponse(R"({"data":[]})"); + REQUIRE(out.isOk()); + CHECK(out.value().empty()); + } + + TEST_CASE("missing data array returns an error") { + const auto out = PokemonSetSource::parseResponse(R"({"meta":{}})"); + CHECK(out.isErr()); + } + + TEST_CASE("invalid JSON returns an error") { + const auto out = PokemonSetSource::parseResponse("{not json"); + CHECK(out.isErr()); + } +} + +TEST_SUITE("PokemonSetSource::fetchAll") { + TEST_CASE("network error is surfaced as a Result error") { + FixedHttpClient http; + http.ok = false; + PokemonSetSource src{http}; + CHECK(src.fetchAll().isErr()); + } + + TEST_CASE("network success is parsed end-to-end and hits the public endpoint") { + FixedHttpClient http; + http.ok = true; + http.body = R"({"data":[{"id":"x","name":"X","releaseDate":"2020/01/01"}]})"; + PokemonSetSource src{http}; + const auto out = src.fetchAll(); + REQUIRE(out.isOk()); + CHECK(out.value().front().id == "x"); + CHECK(out.value().front().releaseDate == "2020/01/01"); + CHECK(http.lastUrl == "https://api.pokemontcg.io/v2/sets"); + } +} diff --git a/tests/set_service_tests.cpp b/tests/set_service_tests.cpp new file mode 100644 index 0000000..70739f8 --- /dev/null +++ b/tests/set_service_tests.cpp @@ -0,0 +1,119 @@ +#include + +#include "ccm/games/IGameModule.hpp" +#include "ccm/ports/ISetRepository.hpp" +#include "ccm/services/SetService.hpp" + +#include + +using namespace ccm; + +namespace { + +class FakeSetSource final : public ISetSource { +public: + Result> result = Result>::err("not set"); + int calls = 0; + Result> fetchAll() override { + ++calls; + return result; + } +}; + +class FakeGameModule final : public IGameModule { +public: + FakeSetSource source; + Game gameId; + explicit FakeGameModule(Game id) : gameId(id) {} + Game id() const noexcept override { return gameId; } + std::string dirName() const override { return gameId == Game::Magic ? "magic" : "pokemon"; } + std::string displayName() const override { return dirName(); } + ISetSource& setSource() override { return source; } +}; + +class InMemSetRepo final : public ISetRepository { +public: + std::vector stored; + bool hasStored = false; + Result> load(Game) override { + if (!hasStored) return Result>::err("no cache"); + return Result>::ok(stored); + } + Result save(Game, const std::vector& s) override { + stored = s; + hasStored = true; + return Result::ok(); + } +}; + +} // namespace + +TEST_SUITE("SetService") { + TEST_CASE("updateSets fetches via the registered module and persists the result") { + InMemSetRepo repo; + SetService svc{repo}; + + FakeGameModule magic{Game::Magic}; + magic.source.result = Result>::ok({ + {"lea", "Alpha", "1993/08/05"}, + }); + svc.registerModule(&magic); + + const auto out = svc.updateSets(Game::Magic); + REQUIRE(out.isOk()); + CHECK(out.value().size() == 1); + CHECK(magic.source.calls == 1); + + // Cache is now warm. + const auto cached = svc.getSets(Game::Magic); + REQUIRE(cached.isOk()); + CHECK(cached.value() == out.value()); + } + + TEST_CASE("updateSets fails cleanly for unregistered games") { + InMemSetRepo repo; + SetService svc{repo}; + const auto out = svc.updateSets(Game::Pokemon); + CHECK(out.isErr()); + } + + TEST_CASE("propagates upstream fetch errors without persisting") { + InMemSetRepo repo; + SetService svc{repo}; + + FakeGameModule pokemon{Game::Pokemon}; + pokemon.source.result = Result>::err("upstream is down"); + svc.registerModule(&pokemon); + + const auto out = svc.updateSets(Game::Pokemon); + CHECK(out.isErr()); + CHECK_FALSE(repo.hasStored); + } + + TEST_CASE("Pokemon module is routed independently of Magic") { + // Both modules registered; updating Pokemon must hit the Pokemon + // source and persist under the Pokemon Game key without disturbing + // a previously cached Magic list. + InMemSetRepo repo; + SetService svc{repo}; + + FakeGameModule magic{Game::Magic}; + magic.source.result = Result>::ok({ + {"lea", "Alpha", "1993/08/05"}, + }); + FakeGameModule pokemon{Game::Pokemon}; + pokemon.source.result = Result>::ok({ + {"base1", "Base", "1999/01/09"}, + }); + svc.registerModule(&magic); + svc.registerModule(&pokemon); + + REQUIRE(svc.updateSets(Game::Magic).isOk()); + const auto poke = svc.updateSets(Game::Pokemon); + REQUIRE(poke.isOk()); + REQUIRE(poke.value().size() == 1); + CHECK(poke.value().front().id == "base1"); + CHECK(magic.source.calls == 1); + CHECK(pokemon.source.calls == 1); + } +} diff --git a/ui_wx/AGENTS.md b/ui_wx/AGENTS.md new file mode 100644 index 0000000..fe12826 --- /dev/null +++ b/ui_wx/AGENTS.md @@ -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` 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` 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` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr` + `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` 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 ~50–100 lines of hook overrides on top of the matching base template. +- `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`. Same shape as the Magic ones; differences are limited to the Set # field, the Holo / 1. Edition flags, and the Pokemon TCG preview lookup key (which includes `setNo`). +- `include/ccm/ui/SvgIcons.hpp` + `src/SvgIcons.cpp` — embedded SVG templates with a `@FILL@` placeholder. Magic flags: `kSvgFoil` / `kSvgSigned` / `kSvgAltered`. Pokemon flags: `kSvgHolo` (sparkle, mirroring the original `IconHolo` from `PokemonTable.tsx`) and `kSvgFirstEdition` (themed "1" inside an outlined badge, rebuilt from the original `IconPokemonFirstEdition.tsx` — every fill/stroke uses `@FILL@` so the icon themes alongside the others). Toolbar glyphs: `kSvgToolbarAdd` / `kSvgToolbarEdit` / `kSvgToolbarDelete` (vscode-codicons). `svgIconBitmap` / `paddedSvgIcon` helpers backed by `wxBitmapBundle::FromSVG`. Bitmaps from `svgIconBitmap` go straight to `wxStaticBitmap` / `wxBitmapButton::SetBitmap` cleanly; for the row-icon path `IconListCtrl` packs them into a private premultiplied-BGRA `HIMAGELIST` and draws with `ImageList_Draw`. See convention 11 for the full pitfall write-up. +- `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` holding `std::atomic alive`, `std::atomic 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: + - `CardListPanel : public BaseCardListPanel<Card, SortColumn>` — override `declareTextColumns()`, `declareIconColumns()`, `renderTextCell()`, `isIconColumnSet()`, `sortBy()`, `matchesFilter()`. + - `SelectedCardPanel : public BaseSelectedCardPanel<Card>` — override `declareDetailRows()`, `declareFlagIcons()`, `detailValueFor()`, `isFlagSet()`, `previewKey()`, `gameId()`. Define a local `enum` of `DetailKey` constants for clarity. + - `CardEditDialog : public BaseCardEditDialog<Card>` — override `buildFlagsRow()`, optionally `appendExtraRows()`, `readExtraFromCard()`, `writeExtraToCard()`, `updateMenuName()`. +2. Add a `GameView : public IGameView` that owns those panels and the typed `CollectionService<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` diff --git a/ui_wx/CMakeLists.txt b/ui_wx/CMakeLists.txt new file mode 100644 index 0000000..13416f0 --- /dev/null +++ b/ui_wx/CMakeLists.txt @@ -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 + $ + $ +) + +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) diff --git a/ui_wx/include/ccm/ui/AppContext.hpp b/ui_wx/include/ccm/ui/AppContext.hpp new file mode 100644 index 0000000..bbda406 --- /dev/null +++ b/ui_wx/include/ccm/ui/AppContext.hpp @@ -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` - 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 + +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 gameViews; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/AppVersion.hpp.in b/ui_wx/include/ccm/ui/AppVersion.hpp.in new file mode 100644 index 0000000..e583441 --- /dev/null +++ b/ui_wx/include/ccm/ui/AppVersion.hpp.in @@ -0,0 +1,7 @@ +#pragma once + +namespace ccm::ui { + +inline constexpr const char* kAppVersion = "@CCM_APP_VERSION@"; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp b/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp new file mode 100644 index 0000000..5462449 --- /dev/null +++ b/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp @@ -0,0 +1,524 @@ +#pragma once + +// BaseCardEditDialog +// +// 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __WXMSW__ +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ccm::ui { + +enum class EditMode { Create, Edit }; + +template +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* 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& 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(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(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(amountCtrl_->GetValue()); + card_.note = noteCtrl_->GetValue().ToStdString(wxConvUTF8); + + if (!available.empty() && setCombo_->IsEnabled()) { + const int sel = setCombo_->GetSelection(); + if (sel >= 0 && static_cast(sel) < available.size()) { + card_.set = available[static_cast(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 failed; + failed.reserve(static_cast(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(sel)); + } + + void onImageActivated(wxCommandEvent& event) { + const int sel = event.GetSelection(); + if (sel < 0 || static_cast(sel) >= card_.images.size()) return; + + std::vector 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(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(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(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(ev.GetUnicodeKey()); + if (uc == WXK_NONE && code == WXK_SPACE) + uc = wxT(' '); + if (uc == WXK_NONE && code >= 32 && code < 127) + uc = static_cast(code); + + if (uc == WXK_NONE || static_cast(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 sets_; + const std::vector* 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 diff --git a/ui_wx/include/ccm/ui/BaseCardListPanel.hpp b/ui_wx/include/ccm/ui/BaseCardListPanel.hpp new file mode 100644 index 0000000..dba1d9d --- /dev/null +++ b/ui_wx/include/ccm/ui/BaseCardListPanel.hpp @@ -0,0 +1,681 @@ +#pragma once + +// BaseCardListPanel +// +// 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 +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 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 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 keepId; + if (auto sel = selected()) keepId = sel->id; + rebuildRows(keepId); + Refresh(); + } + + [[nodiscard]] const std::vector& cards() const noexcept { return cards_; } + [[nodiscard]] const std::string& filter() const noexcept { return filter_; } + [[nodiscard]] std::optional 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 sortColumn; // none = not sortable + }; + + struct IconColumnSpec { + const char* svg; + int width; + std::optional 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 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 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(iconIdx) >= iconCols_.size()) { + return false; + } + return isIconColumnSet(*c, static_cast(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(textCols_.size()) - 1; + } + [[nodiscard]] int noteColIdx() const noexcept { + return firstIconColIdx() + static_cast(iconCols_.size()); + } + [[nodiscard]] int textColCount() const noexcept { + return static_cast(textCols_.size()); + } + [[nodiscard]] int iconColCount() const noexcept { + return static_cast(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& 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(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(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(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(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(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(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(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(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 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(listColIdx - firstTextColIdx()); + if (i < textCols_.size() - 1) return textCols_[i].sortColumn; + } else if (listColIdx < noteCol) { + const std::size_t i = static_cast(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 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 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(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(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 headerCells_; + std::vector> headerIcons_; + std::unordered_map headerCellToCol_; + IconListCtrl* list_{nullptr}; + + std::vector textCols_; + std::vector iconCols_; + + bool resizingCol_{false}; + bool suppressNextHeaderClick_{false}; + bool autoSizedOnce_{false}; + int activeResizeCol_{-1}; + int resizeStartScreenX_{0}; + int resizeStartWidth_{0}; + + std::vector cards_; + std::vector filteredIndices_; + std::string filter_; + + // Rebuild guard - see ui_wx/AGENTS.md for the burst-suppression rationale. + bool inRebuild_{false}; + + std::map nextDirByCol_; + + // Per-icon-column cached bitmaps consumed by `IconListCtrl`'s NM_CUSTOMDRAW + // path. Index aligns with `iconCols_`. + std::vector iconBitmapsNormal_; + std::vector iconBitmapsSelected_; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp b/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp new file mode 100644 index 0000000..84dbbc4 --- /dev/null +++ b/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp @@ -0,0 +1,500 @@ +#pragma once + +// BaseSelectedCardPanel +// +// 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` + `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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 +class BaseSelectedCardPanel : public wxPanel { +public: + using card_type = TCard; + + void setCard(std::optional card) { + const std::optional newId = + card ? std::optional{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 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 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 + 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()) { + 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 alive{true}; + std::atomic 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(sel) >= card_->images.size()) return; + + std::vector 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(sel)); + const Theme theme = inferThemeFromWindow(this); + applyThemeToWindowTree(&dlg, paletteForTheme(theme), theme); + dlg.ShowModal(); + } + + ImageService& imageService_; + CardPreviewService& cardPreview_; + std::optional card_; + + wxStaticBitmap* previewBitmap_{nullptr}; + wxStaticText* previewStatus_{nullptr}; + + std::vector detailRows_; + + wxStaticText* flagsLabel_{nullptr}; + wxPanel* flagsRow_{nullptr}; + std::vector flagIcons_; + + wxStaticText* noteLabel_{nullptr}; + wxStaticText* noteValue_{nullptr}; + + wxListBox* imageList_{nullptr}; + + std::shared_ptr state_; + std::optional lastFetchedId_; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/IGameView.hpp b/ui_wx/include/ccm/ui/IGameView.hpp new file mode 100644 index 0000000..cb145ff --- /dev/null +++ b/ui_wx/include/ccm/ui/IGameView.hpp @@ -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 +#include +#include + +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 " menu entry. + [[nodiscard]] virtual std::string updateSetsMenuLabel() const = 0; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/IconListCtrl.hpp b/ui_wx/include/ccm/ui/IconListCtrl.hpp new file mode 100644 index 0000000..87bac77 --- /dev/null +++ b/ui_wx/include/ccm/ui/IconListCtrl.hpp @@ -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 +#include + +#include +#include +#include + +namespace ccm::ui { + +class IconListCtrl : public wxListCtrl { +public: + using wxListCtrl::wxListCtrl; + + using IconPredicate = std::function; + + ~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 normal, + std::vector 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 normalBmps_; + std::vector selectedBmps_; + + // Premultiplied BGRA DIB sections used as the source for `AlphaBlend`. + // Stored as `void*` (HBITMAP) so the header stays free of ``. + // Index layout: + // [0 .. iconColCount_) -> normal variants + // [iconColCount_ .. 2 * iconColCount_) -> selected variants + std::vector dibBitmaps_; + int dibWidth_{0}; + int dibHeight_{0}; + + void rebuildDibCache(); + void destroyDibCache(); +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/ImageViewerDialog.hpp b/ui_wx/include/ccm/ui/ImageViewerDialog.hpp new file mode 100644 index 0000000..85dd1ee --- /dev/null +++ b/ui_wx/include/ccm/ui/ImageViewerDialog.hpp @@ -0,0 +1,42 @@ +#pragma once + +// ImageViewerDialog: full-size image viewer with prev/next navigation. + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace ccm::ui { + +class ImageViewerDialog : public wxDialog { +public: + ImageViewerDialog(wxWindow* parent, + std::vector 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 paths_; + std::size_t index_{0}; + std::vector imageCache_; + std::vector imageCacheReady_; + + wxPanel* imageHost_{nullptr}; + wxStaticText* caption_{nullptr}; + wxButton* prevButton_{nullptr}; + wxButton* nextButton_{nullptr}; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/MagicCardEditDialog.hpp b/ui_wx/include/ccm/ui/MagicCardEditDialog.hpp new file mode 100644 index 0000000..4684dc4 --- /dev/null +++ b/ui_wx/include/ccm/ui/MagicCardEditDialog.hpp @@ -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` and only overrides the flags row. + +#include "ccm/domain/MagicCard.hpp" +#include "ccm/ui/BaseCardEditDialog.hpp" + +namespace ccm::ui { + +class MagicCardEditDialog final : public BaseCardEditDialog { +public: + MagicCardEditDialog(wxWindow* parent, + ImageService& imageService, + SetService& setService, + EditMode mode, + MagicCard initial, + const std::vector* 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 diff --git a/ui_wx/include/ccm/ui/MagicCardListPanel.hpp b/ui_wx/include/ccm/ui/MagicCardListPanel.hpp new file mode 100644 index 0000000..1dcaf8a --- /dev/null +++ b/ui_wx/include/ccm/ui/MagicCardListPanel.hpp @@ -0,0 +1,34 @@ +#pragma once + +// MagicCardListPanel: typed view of the Magic collection. Inherits all +// `wxListCtrl`/themed-header machinery from `BaseCardListPanel`; 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 { +public: + explicit MagicCardListPanel(wxWindow* parent); + +protected: + [[nodiscard]] std::vector declareTextColumns() const override; + [[nodiscard]] std::vector 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 diff --git a/ui_wx/include/ccm/ui/MagicGameView.hpp b/ui_wx/include/ccm/ui/MagicGameView.hpp new file mode 100644 index 0000000..fc3d7bb --- /dev/null +++ b/ui_wx/include/ccm/ui/MagicGameView.hpp @@ -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` 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 +#include +#include + +namespace ccm::ui { + +class MagicCardListPanel; +class MagicSelectedCardPanel; + +class MagicGameView final : public IGameView { +public: + MagicGameView(ConfigService& config, + CollectionService& 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& setsForDialog(); + + ConfigService& config_; + CollectionService& collection_; + SetService& sets_; + ImageService& images_; + CardPreviewService& cardPreview_; + IGameModule& module_; + + MagicCardListPanel* listPanel_{nullptr}; + MagicSelectedCardPanel* selectedPanel_{nullptr}; + std::vector setsCache_; + bool attemptedInitialSetLoad_{false}; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/MagicSelectedCardPanel.hpp b/ui_wx/include/ccm/ui/MagicSelectedCardPanel.hpp new file mode 100644 index 0000000..abdcb8c --- /dev/null +++ b/ui_wx/include/ccm/ui/MagicSelectedCardPanel.hpp @@ -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` 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 { +public: + MagicSelectedCardPanel(wxWindow* parent, + ImageService& imageService, + CardPreviewService& cardPreview); + +protected: + [[nodiscard]] std::vector declareDetailRows() const override; + [[nodiscard]] std::vector 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 + previewKey(const MagicCard& card) const override; + [[nodiscard]] Game gameId() const noexcept override { return Game::Magic; } +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/MainFrame.hpp b/ui_wx/include/ccm/ui/MainFrame.hpp new file mode 100644 index 0000000..f4323cb --- /dev/null +++ b/ui_wx/include/ccm/ui/MainFrame.hpp @@ -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 +#include + +#include + +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 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 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 diff --git a/ui_wx/include/ccm/ui/PokemonCardEditDialog.hpp b/ui_wx/include/ccm/ui/PokemonCardEditDialog.hpp new file mode 100644 index 0000000..80541af --- /dev/null +++ b/ui_wx/include/ccm/ui/PokemonCardEditDialog.hpp @@ -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` 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 { +public: + PokemonCardEditDialog(wxWindow* parent, + ImageService& imageService, + SetService& setService, + EditMode mode, + PokemonCard initial, + const std::vector* 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 diff --git a/ui_wx/include/ccm/ui/PokemonCardListPanel.hpp b/ui_wx/include/ccm/ui/PokemonCardListPanel.hpp new file mode 100644 index 0000000..6355b2f --- /dev/null +++ b/ui_wx/include/ccm/ui/PokemonCardListPanel.hpp @@ -0,0 +1,26 @@ +#pragma once + +// PokemonCardListPanel: typed view of the Pokemon collection. Inherits all +// `wxListCtrl`/themed-header machinery from `BaseCardListPanel` 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 { +public: + explicit PokemonCardListPanel(wxWindow* parent); + +protected: + [[nodiscard]] std::vector declareTextColumns() const override; + [[nodiscard]] std::vector 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 diff --git a/ui_wx/include/ccm/ui/PokemonGameView.hpp b/ui_wx/include/ccm/ui/PokemonGameView.hpp new file mode 100644 index 0000000..f9af24d --- /dev/null +++ b/ui_wx/include/ccm/ui/PokemonGameView.hpp @@ -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` 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 +#include +#include + +namespace ccm::ui { + +class PokemonCardListPanel; +class PokemonSelectedCardPanel; + +class PokemonGameView final : public IGameView { +public: + PokemonGameView(ConfigService& config, + CollectionService& 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& setsForDialog(); + + ConfigService& config_; + CollectionService& collection_; + SetService& sets_; + ImageService& images_; + CardPreviewService& cardPreview_; + IGameModule& module_; + + PokemonCardListPanel* listPanel_{nullptr}; + PokemonSelectedCardPanel* selectedPanel_{nullptr}; + std::vector setsCache_; + bool attemptedInitialSetLoad_{false}; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/PokemonSelectedCardPanel.hpp b/ui_wx/include/ccm/ui/PokemonSelectedCardPanel.hpp new file mode 100644 index 0000000..8a41bc0 --- /dev/null +++ b/ui_wx/include/ccm/ui/PokemonSelectedCardPanel.hpp @@ -0,0 +1,30 @@ +#pragma once + +// PokemonSelectedCardPanel: typed view of the right-hand-side detail panel +// for Pokemon TCG cards. Inherits from `BaseSelectedCardPanel` +// 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 { +public: + PokemonSelectedCardPanel(wxWindow* parent, + ImageService& imageService, + CardPreviewService& cardPreview); + +protected: + [[nodiscard]] std::vector declareDetailRows() const override; + [[nodiscard]] std::vector 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 + previewKey(const PokemonCard& card) const override; + [[nodiscard]] Game gameId() const noexcept override { return Game::Pokemon; } +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/SettingsDialog.hpp b/ui_wx/include/ccm/ui/SettingsDialog.hpp new file mode 100644 index 0000000..caa174c --- /dev/null +++ b/ui_wx/include/ccm/ui/SettingsDialog.hpp @@ -0,0 +1,28 @@ +#pragma once + +// SettingsDialog: edits the live Configuration via ConfigService. + +#include "ccm/services/ConfigService.hpp" + +#include +#include +#include + +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 diff --git a/ui_wx/include/ccm/ui/SvgIcons.hpp b/ui_wx/include/ccm/ui/SvgIcons.hpp new file mode 100644 index 0000000..36bf18a --- /dev/null +++ b/ui_wx/include/ccm/ui/SvgIcons.hpp @@ -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 + +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 diff --git a/ui_wx/include/ccm/ui/Theme.hpp b/ui_wx/include/ccm/ui/Theme.hpp new file mode 100644 index 0000000..5b3b3b5 --- /dev/null +++ b/ui_wx/include/ccm/ui/Theme.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "ccm/domain/Enums.hpp" + +#include + +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 diff --git a/ui_wx/src/BaseEvents.cpp b/ui_wx/src/BaseEvents.cpp new file mode 100644 index 0000000..8cc8ed5 --- /dev/null +++ b/ui_wx/src/BaseEvents.cpp @@ -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 diff --git a/ui_wx/src/IconListCtrl.cpp b/ui_wx/src/IconListCtrl.cpp new file mode 100644 index 0000000..dd5d17d --- /dev/null +++ b/ui_wx/src/IconListCtrl.cpp @@ -0,0 +1,243 @@ +#include "ccm/ui/IconListCtrl.hpp" + +#include + +#ifdef __WXMSW__ +#include +#include +#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(dibPixels); + const int pixels = w * h; + const auto premul = [](unsigned c, unsigned a) -> unsigned char { + return static_cast((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 normal, + std::vector selected) { + normalBmps_ = std::move(normal); + selectedBmps_ = std::move(selected); + rebuildDibCache(); +} + +void IconListCtrl::destroyDibCache() { + for (void* p : dibBitmaps_) { + if (p != nullptr) ::DeleteObject(static_cast(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(makePremultipliedDib(b))); + } + for (const auto& b : selectedBmps_) { + dibBitmaps_.push_back(static_cast(makePremultipliedDib(b))); + } +} + +bool IconListCtrl::MSWOnNotify(int idCtrl, WXLPARAM lParam, WXLPARAM* result) { + auto* hdr = reinterpret_cast(lParam); + if (hdr != nullptr && hdr->code == NM_CUSTOMDRAW) { + auto* cd = reinterpret_cast(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(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(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(row), + reinterpret_cast(&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(iconIdx) + + (selected ? static_cast(iconColCount_) : 0); + if (imgIdx >= dibBitmaps_.size() || dibBitmaps_[imgIdx] == nullptr) { + *result = CDRF_DODEFAULT; + return true; + } + HBITMAP src = static_cast(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 normal, + std::vector selected) { + normalBmps_ = std::move(normal); + selectedBmps_ = std::move(selected); +} + +void IconListCtrl::destroyDibCache() {} +void IconListCtrl::rebuildDibCache() {} + +#endif // __WXMSW__ + +} // namespace ccm::ui diff --git a/ui_wx/src/ImageViewerDialog.cpp b/ui_wx/src/ImageViewerDialog.cpp new file mode 100644 index 0000000..8b5f278 --- /dev/null +++ b/ui_wx/src/ImageViewerDialog.cpp @@ -0,0 +1,163 @@ +#include "ccm/ui/ImageViewerDialog.hpp" + +#include +#include +#include +#include +#include +#include + +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(ws.GetWidth()) / original_.GetWidth(), + static_cast(ws.GetHeight()) / original_.GetHeight()); + const int w = std::max(1, static_cast(original_.GetWidth() * scale)); + const int h = std::max(1, static_cast(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(original_.GetWidth()) * original_.GetHeight(); + const long long dstPixels = static_cast(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 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(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 diff --git a/ui_wx/src/MagicCardEditDialog.cpp b/ui_wx/src/MagicCardEditDialog.cpp new file mode 100644 index 0000000..7742596 --- /dev/null +++ b/ui_wx/src/MagicCardEditDialog.cpp @@ -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* preloadedSets) + : BaseCardEditDialog( + 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 diff --git a/ui_wx/src/MagicCardListPanel.cpp b/ui_wx/src/MagicCardListPanel.cpp new file mode 100644 index 0000000..2509790 --- /dev/null +++ b/ui_wx/src/MagicCardListPanel.cpp @@ -0,0 +1,70 @@ +#include "ccm/ui/MagicCardListPanel.hpp" + +#include "ccm/services/CardFilter.hpp" +#include "ccm/ui/SvgIcons.hpp" + +#include + +namespace ccm::ui { + +MagicCardListPanel::MagicCardListPanel(wxWindow* parent) + : BaseCardListPanel(parent) { + buildLayout(); +} + +std::vector +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::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 diff --git a/ui_wx/src/MagicGameView.cpp b/ui_wx/src/MagicGameView.cpp new file mode 100644 index 0000000..eb3db0e --- /dev/null +++ b/ui_wx/src/MagicGameView.cpp @@ -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 + +#include +#include + +namespace ccm::ui { + +MagicGameView::MagicGameView(ConfigService& config, + CollectionService& 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& 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 diff --git a/ui_wx/src/MagicSelectedCardPanel.cpp b/ui_wx/src/MagicSelectedCardPanel.cpp new file mode 100644 index 0000000..3005cd0 --- /dev/null +++ b/ui_wx/src/MagicSelectedCardPanel.cpp @@ -0,0 +1,77 @@ +#include "ccm/ui/MagicSelectedCardPanel.hpp" + +#include "ccm/ui/SvgIcons.hpp" + +#include + +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(parent, imageService, cardPreview) { + buildLayout(); +} + +std::vector +MagicSelectedCardPanel::declareDetailRows() const { + return { + {"Name", kName, "(no card selected)"}, + {"Set", kSet, ""}, + {"Language", kLanguage, ""}, + {"Condition", kCondition, ""}, + {"Amount", kAmount, ""}, + }; +} + +std::vector +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 +MagicSelectedCardPanel::previewKey(const MagicCard& card) const { + return {card.name, card.set.id, std::string{}}; +} + +} // namespace ccm::ui diff --git a/ui_wx/src/MainFrame.cpp b/ui_wx/src/MainFrame.cpp new file mode 100644 index 0000000..eaf4b8c --- /dev/null +++ b/ui_wx/src/MainFrame.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#ifdef __WXMSW__ +#include +#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(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(lParam); + const HWND filterHwnd = reinterpret_cast(filterInput_->GetHandle()); + if (target != nullptr && filterHwnd != nullptr && target == filterHwnd) { + const ThemePalette palette = paletteForTheme(ctx_.config.current().theme); + HDC hdc = reinterpret_cast(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(::GetStockObject(DC_BRUSH)); + } + } + return wxFrame::MSWWindowProc(message, wParam, lParam); +} +#endif + +} // namespace ccm::ui diff --git a/ui_wx/src/PokemonCardEditDialog.cpp b/ui_wx/src/PokemonCardEditDialog.cpp new file mode 100644 index 0000000..4550c52 --- /dev/null +++ b/ui_wx/src/PokemonCardEditDialog.cpp @@ -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* preloadedSets) + : BaseCardEditDialog( + 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 diff --git a/ui_wx/src/PokemonCardListPanel.cpp b/ui_wx/src/PokemonCardListPanel.cpp new file mode 100644 index 0000000..f866c3a --- /dev/null +++ b/ui_wx/src/PokemonCardListPanel.cpp @@ -0,0 +1,75 @@ +#include "ccm/ui/PokemonCardListPanel.hpp" + +#include "ccm/services/CardFilter.hpp" +#include "ccm/ui/SvgIcons.hpp" + +#include + +namespace ccm::ui { + +PokemonCardListPanel::PokemonCardListPanel(wxWindow* parent) + : BaseCardListPanel(parent) { + buildLayout(); +} + +std::vector +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::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 diff --git a/ui_wx/src/PokemonGameView.cpp b/ui_wx/src/PokemonGameView.cpp new file mode 100644 index 0000000..344ccd5 --- /dev/null +++ b/ui_wx/src/PokemonGameView.cpp @@ -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 + +#include +#include + +namespace ccm::ui { + +PokemonGameView::PokemonGameView(ConfigService& config, + CollectionService& 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& 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 diff --git a/ui_wx/src/PokemonSelectedCardPanel.cpp b/ui_wx/src/PokemonSelectedCardPanel.cpp new file mode 100644 index 0000000..ceecdde --- /dev/null +++ b/ui_wx/src/PokemonSelectedCardPanel.cpp @@ -0,0 +1,82 @@ +#include "ccm/ui/PokemonSelectedCardPanel.hpp" + +#include "ccm/ui/SvgIcons.hpp" + +#include + +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(parent, imageService, cardPreview) { + buildLayout(); +} + +std::vector +PokemonSelectedCardPanel::declareDetailRows() const { + return { + {"Name", kName, "(no card selected)"}, + {"Set", kSet, ""}, + {"Set #", kSetNo, ""}, + {"Language", kLanguage, ""}, + {"Condition", kCondition, ""}, + {"Amount", kAmount, ""}, + }; +} + +std::vector +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 +PokemonSelectedCardPanel::previewKey(const PokemonCard& card) const { + return {card.name, card.set.id, card.setNo}; +} + +} // namespace ccm::ui diff --git a/ui_wx/src/SettingsDialog.cpp b/ui_wx/src/SettingsDialog.cpp new file mode 100644 index 0000000..1e985a2 --- /dev/null +++ b/ui_wx/src/SettingsDialog.cpp @@ -0,0 +1,97 @@ +#include "ccm/ui/SettingsDialog.hpp" +#include "ccm/ui/Theme.hpp" + +#include +#include +#include +#include +#include + +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 diff --git a/ui_wx/src/SvgIcons.cpp b/ui_wx/src/SvgIcons.cpp new file mode 100644 index 0000000..e16dcf7 --- /dev/null +++ b/ui_wx/src/SvgIcons.cpp @@ -0,0 +1,118 @@ +#include "ccm/ui/SvgIcons.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace ccm::ui { + +const char* const kSvgFoil = R"SVG( + + +)SVG"; + +const char* const kSvgSigned = R"SVG( + + +)SVG"; + +const char* const kSvgAltered = R"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( + + +)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 `` +// element because NanoSVG (the SVG backend behind `wxBitmapBundle::FromSVG`) +// does not render text nodes. +const char* const kSvgFirstEdition = R"SVG( + + + + +)SVG"; + +// vscode-codicons — MIT License (Microsoft). Paths mirror VscAdd / +// VscEdit / VscTrash from react-icons/vsc. +const char* const kSvgToolbarAdd = R"SVG( + + +)SVG"; + +const char* const kSvgToolbarEdit = R"SVG( + + +)SVG"; + +const char* const kSvgToolbarDelete = R"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(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 diff --git a/ui_wx/src/Theme.cpp b/ui_wx/src/Theme.cpp new file mode 100644 index 0000000..fad3983 --- /dev/null +++ b/ui_wx/src/Theme.cpp @@ -0,0 +1,684 @@ +#include "ccm/ui/Theme.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#ifdef __WXMSW__ +#include +#include +#endif + +namespace ccm::ui { + +namespace { +std::unordered_set gButtonHoverBound; +std::unordered_set 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 gButtonVisualStates; +struct GripVisualState { + wxColour bg; + wxColour line; +}; +std::unordered_map gGripVisualStates; + +wxColour lightenTowardWhite(const wxColour& c, int amount) { + auto lift = [amount](unsigned char channel) -> unsigned char { + const int raised = static_cast(channel) + amount; + return static_cast(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(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(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( + uxthemeModule ? ::GetProcAddress(uxthemeModule, "SetWindowTheme") : nullptr); + return setWindowTheme; +} + +AllowDarkModeForWindowFn resolveAllowDarkModeForWindow() { + static HMODULE uxthemeModule = ::LoadLibraryW(L"uxtheme.dll"); + static auto fn = reinterpret_cast( + uxthemeModule ? ::GetProcAddress(uxthemeModule, MAKEINTRESOURCEA(133)) : nullptr); + return fn; +} + +SetPreferredAppModeFn resolveSetPreferredAppMode() { + static HMODULE uxthemeModule = ::LoadLibraryW(L"uxtheme.dll"); + static auto fn = reinterpret_cast( + uxthemeModule ? ::GetProcAddress(uxthemeModule, MAKEINTRESOURCEA(135)) : nullptr); + return fn; +} + +FlushMenuThemesFn resolveFlushMenuThemes() { + static HMODULE uxthemeModule = ::LoadLibraryW(L"uxtheme.dll"); + static auto fn = reinterpret_cast( + 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(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(window); + if (list == nullptr) return; + + const HWND listHwnd = reinterpret_cast(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(toColorRef(palette.inputBg))); + ::SendMessageW(header, HDM_SETTEXTCOLOR, 0, static_cast(toColorRef(palette.inputText))); + InvalidateRect(header, nullptr, TRUE); +} + +void applyFrameTitlebarTheme(wxWindow* window, Theme theme) { + if (dynamic_cast(window) == nullptr) return; + + const HWND hwnd = reinterpret_cast(window->GetHandle()); + if (hwnd == nullptr) return; + + static HMODULE dwmModule = ::LoadLibraryW(L"dwmapi.dll"); + static auto dwmSetWindowAttribute = reinterpret_cast( + 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(window) == nullptr) return; + + const HWND top = reinterpret_cast(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 enumCtx{theme, setWindowTheme}; + + ::EnumChildWindows( + top, + [](HWND child, LPARAM lParam) -> BOOL { + auto* ctx = reinterpret_cast*>(lParam); + if (ctx == nullptr || ctx->second == nullptr) return TRUE; + + wchar_t className[64] = {}; + if (::GetClassNameW(child, className, static_cast(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(&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(root) != nullptr || + dynamic_cast(root) != nullptr || + dynamic_cast(root) != nullptr || + dynamic_cast(root) != nullptr || + dynamic_cast(root) != nullptr) { + if (auto* text = dynamic_cast(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(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(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(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(root) != nullptr || + dynamic_cast(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(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(root) != nullptr || + dynamic_cast(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(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(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