diff --git a/core/include/ccm/services/DigiBattle99SetCompletion.hpp b/core/include/ccm/services/DigiBattle99SetCompletion.hpp index ca1abe8..a06eaba 100644 --- a/core/include/ccm/services/DigiBattle99SetCompletion.hpp +++ b/core/include/ccm/services/DigiBattle99SetCompletion.hpp @@ -3,12 +3,15 @@ // Pure helpers: Digi-Battle set-completion progress and per-set checklists. // Ownership counts only when collection card.set.id matches the pack and the // normalized setNo appears in that pack's catalog. Duplicates / amount do not -// inflate the numerator. +// inflate the numerator. An optional languageFilter restricts ownership to +// cards of that language (packs with zero matches are omitted). #include "ccm/domain/DigiBattle99Card.hpp" #include "ccm/domain/DigiBattle99SetCatalog.hpp" +#include "ccm/domain/Enums.hpp" #include +#include #include #include #include @@ -33,16 +36,24 @@ struct DigiBattle99ChecklistEntry { bool owned{false}; }; +// Distinct languages present in the collection, in allLanguages() order. +[[nodiscard]] std::vector +digiBattle99LanguagesInCollection(const std::vector& collection); + // Packs where the collection owns ≥1 card with matching set.id, ordered by -// setName. Packs absent from the catalog are skipped. +// setName. Packs absent from the catalog are skipped. When languageFilter is +// set, only cards of that language count toward ownership. [[nodiscard]] std::vector computeDigiBattle99SetCompletion(const std::vector& collection, - const DigiBattle99SetCatalog& catalog); + const DigiBattle99SetCatalog& catalog, + std::optional languageFilter = std::nullopt); // Full catalog checklist for one pack; owned flags from the collection. +// When languageFilter is set, only cards of that language count as owned. [[nodiscard]] std::vector digiBattle99ChecklistForSet(const std::vector& collection, const DigiBattle99SetCatalog& catalog, - std::string_view setId); + std::string_view setId, + std::optional languageFilter = std::nullopt); } // namespace ccm diff --git a/core/src/services/DigiBattle99SetCompletion.cpp b/core/src/services/DigiBattle99SetCompletion.cpp index c338d5f..44273cf 100644 --- a/core/src/services/DigiBattle99SetCompletion.cpp +++ b/core/src/services/DigiBattle99SetCompletion.cpp @@ -3,6 +3,7 @@ #include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp" #include +#include #include #include @@ -12,9 +13,16 @@ namespace { using OwnedBySet = std::unordered_map>; -OwnedBySet ownedSetNosBySetId(const std::vector& collection) { +bool passesLanguageFilter(const DigiBattle99Card& card, + std::optional languageFilter) { + return !languageFilter.has_value() || card.language == *languageFilter; +} + +OwnedBySet ownedSetNosBySetId(const std::vector& collection, + std::optional languageFilter) { OwnedBySet out; for (const auto& card : collection) { + if (!passesLanguageFilter(card, languageFilter)) continue; if (card.set.id.empty()) continue; const std::string setNo = DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo); @@ -26,10 +34,31 @@ OwnedBySet ownedSetNosBySetId(const std::vector& collection) { } // namespace +std::vector +digiBattle99LanguagesInCollection(const std::vector& collection) { + const auto& langs = allLanguages(); + std::array present{}; + for (const auto& card : collection) { + for (std::size_t i = 0; i < langs.size(); ++i) { + if (langs[i] == card.language) { + present[i] = true; + break; + } + } + } + + std::vector out; + for (std::size_t i = 0; i < langs.size(); ++i) { + if (present[i]) out.push_back(langs[i]); + } + return out; +} + std::vector computeDigiBattle99SetCompletion(const std::vector& collection, - const DigiBattle99SetCatalog& catalog) { - const OwnedBySet owned = ownedSetNosBySetId(collection); + const DigiBattle99SetCatalog& catalog, + std::optional languageFilter) { + const OwnedBySet owned = ownedSetNosBySetId(collection, languageFilter); std::vector out; out.reserve(owned.size()); @@ -64,12 +93,14 @@ computeDigiBattle99SetCompletion(const std::vector& collection std::vector digiBattle99ChecklistForSet(const std::vector& collection, const DigiBattle99SetCatalog& catalog, - std::string_view setId) { + std::string_view setId, + std::optional languageFilter) { const auto* pack = catalog.findPack(setId); if (pack == nullptr) return {}; std::unordered_set ownedNos; for (const auto& card : collection) { + if (!passesLanguageFilter(card, languageFilter)) continue; if (card.set.id != setId) continue; const std::string setNo = DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo); diff --git a/tests/digibattle99_set_completion_tests.cpp b/tests/digibattle99_set_completion_tests.cpp index cad7581..27f1b43 100644 --- a/tests/digibattle99_set_completion_tests.cpp +++ b/tests/digibattle99_set_completion_tests.cpp @@ -119,6 +119,54 @@ TEST_SUITE("computeDigiBattle99SetCompletion") { }; CHECK(computeDigiBattle99SetCompletion(collection, catalog).empty()); } + + TEST_CASE("language filter hides packs with no cards in that language") { + const auto catalog = sampleCatalog(); + DigiBattle99Card en = + makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"); + en.language = Language::English; + + const auto allRows = computeDigiBattle99SetCompletion({en}, catalog); + REQUIRE(allRows.size() == 1); + + const auto deRows = + computeDigiBattle99SetCompletion({en}, catalog, Language::German); + CHECK(deRows.empty()); + + const auto enRows = + computeDigiBattle99SetCompletion({en}, catalog, Language::English); + REQUIRE(enRows.size() == 1); + CHECK(enRows[0].ownedUnique == 1); + } + + TEST_CASE("same setNo in two languages counts once aggregated; filter is exclusive") { + const auto catalog = sampleCatalog(); + DigiBattle99Card en = + makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"); + en.language = Language::English; + DigiBattle99Card de = + makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"); + de.id = 2; + de.language = Language::German; + + const auto allRows = computeDigiBattle99SetCompletion({en, de}, catalog); + REQUIRE(allRows.size() == 1); + CHECK(allRows[0].ownedUnique == 1); + + const auto enRows = + computeDigiBattle99SetCompletion({en, de}, catalog, Language::English); + REQUIRE(enRows.size() == 1); + CHECK(enRows[0].ownedUnique == 1); + + DigiBattle99Card deOnly = + makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02"); + deOnly.id = 3; + deOnly.language = Language::German; + const auto deRows = computeDigiBattle99SetCompletion({en, de, deOnly}, catalog, + Language::German); + REQUIRE(deRows.size() == 1); + CHECK(deRows[0].ownedUnique == 2); + } } TEST_SUITE("digiBattle99ChecklistForSet") { @@ -142,6 +190,57 @@ TEST_SUITE("digiBattle99ChecklistForSet") { const auto catalog = sampleCatalog(); CHECK(digiBattle99ChecklistForSet({}, catalog, "missing").empty()); } + + TEST_CASE("owned flags respect language filter") { + const auto catalog = sampleCatalog(); + DigiBattle99Card en = + makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02"); + en.language = Language::English; + + const auto filtered = + digiBattle99ChecklistForSet({en}, catalog, "series-1-starter-set", + Language::German); + REQUIRE(filtered.size() == 3); + CHECK(filtered[0].owned == false); + CHECK(filtered[1].owned == false); + CHECK(filtered[2].owned == false); + + const auto english = + digiBattle99ChecklistForSet({en}, catalog, "series-1-starter-set", + Language::English); + REQUIRE(english.size() == 3); + CHECK(english[1].owned == true); + } +} + +TEST_SUITE("digiBattle99LanguagesInCollection") { + TEST_CASE("empty collection yields empty") { + CHECK(digiBattle99LanguagesInCollection({}).empty()); + } + + TEST_CASE("returns distinct languages in allLanguages order") { + DigiBattle99Card jp = + makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"); + jp.language = Language::Japanese; + DigiBattle99Card en = + makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02"); + en.id = 2; + en.language = Language::English; + DigiBattle99Card enDup = + makeOwned("series-1-booster-pack", "Series 1 Booster Pack", "BO-01"); + enDup.id = 3; + enDup.language = Language::English; + DigiBattle99Card de = + makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-03"); + de.id = 4; + de.language = Language::German; + + const auto langs = digiBattle99LanguagesInCollection({jp, en, enDup, de}); + REQUIRE(langs.size() == 3); + CHECK(langs[0] == Language::English); + CHECK(langs[1] == Language::German); + CHECK(langs[2] == Language::Japanese); + } } TEST_SUITE("DigiBattle99SetCatalogService") { diff --git a/ui_wx/AGENTS.md b/ui_wx/AGENTS.md index 2917559..3802346 100644 --- a/ui_wx/AGENTS.md +++ b/ui_wx/AGENTS.md @@ -14,7 +14,7 @@ - `include/ccm/ui/SwitchCtrl.hpp` + `src/SwitchCtrl.cpp` — custom pill-track + thumb switch for small modal rows (Yu-Gi-Oh! set picker); fires `EVT_CCM_SWITCH` on user toggle and reads colors from `inferThemeFromWindow` / `paletteForTheme`. - `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/DigiBattle99*.hpp` + `src/DigiBattle99*.cpp` — Digimon Digi-Battle: list/selected/edit plus `DigiBattle99GameView` notebook (**Single Cards** | **Set Completion**) via `contentPanel`, and `DigiBattle99SetCompletionPanel` (pack progress tiles + greyed checklist). Catalog from `DigiBattle99SetCatalogService` (`set-catalog.json`), filled on Update Sets. The Add/Edit/Delete + filter toolbar lives **inside** the Single Cards tab (under the notebook); MainFrame hides its shared toolbar while Digimon is active (`hostsOwnLayout`). +- `include/ccm/ui/DigiBattle99*.hpp` + `src/DigiBattle99*.cpp` — Digimon Digi-Battle: list/selected/edit plus `DigiBattle99GameView` via `contentPanel` with a **palette-painted tab strip** + `wxSimplebook` (**Single Cards** | **Set Completion**) — not native `wxNotebook`, which stays light on MSW dark mode — and `DigiBattle99SetCompletionPanel` (pack progress tiles + greyed checklist). Catalog from `DigiBattle99SetCatalogService` (`set-catalog.json`), filled on Update Sets. The Add/Edit/Delete + filter toolbar lives **inside** the Single Cards page; MainFrame hides its shared toolbar while Digimon is active (`hostsOwnLayout`). - `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`. @@ -28,7 +28,7 @@ 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 content host swaps either the shared `listPanel()` / `selectedPanel()` splitter or a game’s `contentPanel()` when the user picks a different `Game` menu entry. Do not stand up parallel side-by-side tabs for different games. Digimon’s Single Cards / Set Completion notebook is an in-game mode switch, not multi-game tabs. +6. **Single-active-game UX.** `MainFrame` only ever shows one game's panels at a time; the content host swaps either the shared `listPanel()` / `selectedPanel()` splitter or a game’s `contentPanel()` when the user picks a different `Game` menu entry. Do not stand up parallel side-by-side tabs for different games. Digimon’s Single Cards / Set Completion switch is an in-game mode switch (themed tab strip + `wxSimplebook`), not multi-game tabs. 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. diff --git a/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp b/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp index 04b0a24..dffafa2 100644 --- a/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp +++ b/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp @@ -17,8 +17,10 @@ class wxBitmapButton; class wxBoxSizer; -class wxNotebook; +class wxPanel; +class wxSimplebook; class wxSplitterWindow; +class wxStaticText; class wxTextCtrl; namespace ccm::ui { @@ -64,7 +66,10 @@ private: const std::vector& setsForDialog(); void ensureSingleCardsMounted(wxWindow* splitterParent); void buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageSizer); + void buildTabBar(wxWindow* parent, wxBoxSizer* rootSizer); + void selectTab(int index); void refreshToolbarIcons(const ThemePalette& palette); + void refreshTabBarTheme(const ThemePalette& palette); ConfigService& config_; CollectionService& collection_; @@ -75,11 +80,15 @@ private: DigiBattle99SetCatalogService& catalogStore_; wxPanel* contentPanel_{nullptr}; - wxNotebook* notebook_{nullptr}; + wxPanel* tabBar_{nullptr}; + wxSimplebook* book_{nullptr}; wxSplitterWindow* singleSplitter_{nullptr}; DigiBattle99CardListPanel* listPanel_{nullptr}; DigiBattle99SelectedCardPanel* selectedPanel_{nullptr}; DigiBattle99SetCompletionPanel* setCompletionPanel_{nullptr}; + std::array tabPanels_{{nullptr, nullptr}}; + std::array tabLabels_{{nullptr, nullptr}}; + int activeTab_{0}; std::array toolbarButtons_{{nullptr, nullptr, nullptr}}; wxTextCtrl* filterInput_{nullptr}; std::vector setsCache_; diff --git a/ui_wx/include/ccm/ui/DigiBattle99SetCompletionPanel.hpp b/ui_wx/include/ccm/ui/DigiBattle99SetCompletionPanel.hpp index 7daf22a..3e2a466 100644 --- a/ui_wx/include/ccm/ui/DigiBattle99SetCompletionPanel.hpp +++ b/ui_wx/include/ccm/ui/DigiBattle99SetCompletionPanel.hpp @@ -3,18 +3,23 @@ // DigiBattle99SetCompletionPanel: Set Completion tab — pack tiles with // progress bars for sets the user owns ≥1 card of, plus an in-tab checklist // drill-down (unowned rows greyed). Catalog is offline (set-catalog.json). +// Optional language filter restricts ownership to one language and labels +// set titles as "{setName} ({language})". #include "ccm/domain/DigiBattle99Card.hpp" #include "ccm/domain/DigiBattle99SetCatalog.hpp" +#include "ccm/domain/Enums.hpp" #include "ccm/services/DigiBattle99SetCatalogService.hpp" #include "ccm/ui/Theme.hpp" #include +#include #include #include class wxBoxSizer; +class wxChoice; class wxListCtrl; class wxScrolledWindow; class wxSimplebook; @@ -37,13 +42,19 @@ private: void rebuildChecklist(const std::string& setId); void setEmptyMessage(const wxString& message); void clearGridTiles(); + void refreshLanguageChoice(); + void onLanguageChoice(wxCommandEvent& event); + void rebuildCurrentView(); + [[nodiscard]] std::string displaySetName(const std::string& setName) const; DigiBattle99SetCatalogService& catalogStore_; DigiBattle99SetCatalog catalog_; bool catalogLoaded_{false}; std::vector collection_; ThemePalette palette_{}; + std::optional languageFilter_; + wxChoice* languageChoice_{nullptr}; wxSimplebook* book_{nullptr}; wxPanel* gridPage_{nullptr}; wxScrolledWindow* scroll_{nullptr}; @@ -54,6 +65,7 @@ private: wxStaticText* detailTitle_{nullptr}; wxListCtrl* checklist_{nullptr}; std::string detailSetId_; + std::string detailSetName_; }; } // namespace ccm::ui diff --git a/ui_wx/src/DigiBattle99GameView.cpp b/ui_wx/src/DigiBattle99GameView.cpp index 89df750..4548046 100644 --- a/ui_wx/src/DigiBattle99GameView.cpp +++ b/ui_wx/src/DigiBattle99GameView.cpp @@ -10,10 +10,12 @@ #include "ccm/ui/Theme.hpp" #include -#include +#include #include +#include #include #include +#include #include #include @@ -24,6 +26,22 @@ namespace ccm::ui { namespace { constexpr int kDigiToolbarIconPx = 18; constexpr const char kDigiFilterHint[] = "Filter"; + +wxColour lighten(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())); +} + +wxColour darken(const wxColour& c, int amount) { + auto drop = [amount](unsigned char channel) -> unsigned char { + const int lowered = static_cast(channel) - amount; + return static_cast(lowered < 0 ? 0 : lowered); + }; + return wxColour(drop(c.Red()), drop(c.Green()), drop(c.Blue())); +} } // namespace DigiBattle99GameView::DigiBattle99GameView(ConfigService& config, @@ -128,28 +146,141 @@ void DigiBattle99GameView::refreshToolbarIcons(const ThemePalette& palette) { } } +void DigiBattle99GameView::selectTab(int index) { + if (index < 0 || index > 1 || book_ == nullptr) return; + activeTab_ = index; + book_->SetSelection(index); + refreshTabBarTheme(paletteForTheme(config_.current().theme)); +} + +void DigiBattle99GameView::refreshTabBarTheme(const ThemePalette& palette) { + if (tabBar_ == nullptr) return; + + const wxColour barBg = palette.panelBg; + // Match toolbar button plate (Add/Edit/Delete), not a darker inset fill. + const wxColour tabBg = palette.buttonBg; + + tabBar_->SetBackgroundColour(barBg); + tabBar_->SetOwnBackgroundColour(barBg); + + for (int i = 0; i < 2; ++i) { + auto* tab = tabPanels_[i]; + auto* label = tabLabels_[i]; + if (tab == nullptr || label == nullptr) continue; + const bool selected = (i == activeTab_); + tab->SetBackgroundColour(tabBg); + tab->SetOwnBackgroundColour(tabBg); + // Keep the label plate identical to the tab fill so a late theme pass + // cannot leave a darker box around the caption. + label->SetBackgroundColour(tabBg); + label->SetOwnBackgroundColour(tabBg); + label->SetForegroundColour(palette.text); + label->SetOwnForegroundColour(palette.text); + wxFont font = label->GetFont(); + font.SetWeight(selected ? wxFONTWEIGHT_BOLD : wxFONTWEIGHT_NORMAL); + label->SetFont(font); + tab->Refresh(); + label->Refresh(); + } + tabBar_->Layout(); + tabBar_->Refresh(); +} + +void DigiBattle99GameView::buildTabBar(wxWindow* parent, wxBoxSizer* rootSizer) { + tabBar_ = new wxPanel(parent, wxID_ANY); + tabBar_->SetBackgroundStyle(wxBG_STYLE_PAINT); + auto* tabSizer = new wxBoxSizer(wxHORIZONTAL); + tabSizer->AddSpacer(4); + + const char* labels[2] = {"Single Cards", "Set Completion"}; + for (int i = 0; i < 2; ++i) { + auto* tab = new wxPanel(tabBar_, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE); + tab->SetCursor(wxCursor(wxCURSOR_HAND)); + tab->SetBackgroundStyle(wxBG_STYLE_PAINT); + auto* label = new wxStaticText(tab, wxID_ANY, wxString::FromUTF8(labels[i])); + auto* inner = new wxBoxSizer(wxVERTICAL); + // Compact padding so the strip stays short; frame is drawn in paint. + inner->Add(label, 0, wxALIGN_CENTER | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, 5); + tab->SetSizer(inner); + + auto onClick = [this, i](wxMouseEvent&) { selectTab(i); }; + tab->Bind(wxEVT_LEFT_DOWN, onClick); + label->Bind(wxEVT_LEFT_DOWN, onClick); + tab->Bind(wxEVT_ERASE_BACKGROUND, [](wxEraseEvent&) {}); + tab->Bind(wxEVT_PAINT, [this, tab, i](wxPaintEvent&) { + wxPaintDC dc(tab); + const ThemePalette palette = paletteForTheme(config_.current().theme); + const bool dark = config_.current().theme == Theme::Dark; + const bool selected = (i == activeTab_); + // Same plate as toolbar bitmap buttons. + const wxColour bg = palette.buttonBg; + const wxColour frame = + dark ? lighten(palette.panelBg, 55) : darken(palette.panelBg, 45); + const wxColour frameSel = dark ? lighten(palette.panelBg, 85) : darken(palette.panelBg, 70); + const wxRect r = tab->GetClientRect(); + dc.SetPen(wxPen(selected ? frameSel : frame, 1)); + dc.SetBrush(wxBrush(bg)); + dc.DrawRectangle(r.x, r.y, r.width, r.height); + if (selected) { + dc.SetPen(wxPen(palette.text, 2)); + dc.DrawLine(r.GetLeft() + 4, r.GetBottom() - 1, r.GetRight() - 4, + r.GetBottom() - 1); + } + }); + + tabPanels_[i] = tab; + tabLabels_[i] = label; + if (i > 0) tabSizer->AddSpacer(4); + tabSizer->Add(tab, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 3); + } + tabSizer->AddStretchSpacer(1); + + tabBar_->Bind(wxEVT_PAINT, [this](wxPaintEvent&) { + wxPaintDC dc(tabBar_); + const ThemePalette palette = paletteForTheme(config_.current().theme); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(palette.panelBg)); + dc.DrawRectangle(tabBar_->GetClientRect()); + dc.SetPen(wxPen(darken(palette.text, 120), 1)); + const wxRect r = tabBar_->GetClientRect(); + dc.DrawLine(r.GetLeft(), r.GetBottom(), r.GetRight(), r.GetBottom()); + }); + tabBar_->Bind(wxEVT_ERASE_BACKGROUND, [](wxEraseEvent&) {}); + + tabBar_->SetSizer(tabSizer); + rootSizer->Add(tabBar_, 0, wxEXPAND); + refreshTabBarTheme(paletteForTheme(config_.current().theme)); +} + wxPanel* DigiBattle99GameView::contentPanel(wxWindow* parent) { if (contentPanel_ == nullptr) { contentPanel_ = new wxPanel(parent); auto* root = new wxBoxSizer(wxVERTICAL); - notebook_ = new wxNotebook(contentPanel_, wxID_ANY); - auto* singlePage = new wxPanel(notebook_); + buildTabBar(contentPanel_, root); + + book_ = new wxSimplebook(contentPanel_, wxID_ANY); + auto* singlePage = new wxPanel(book_); auto* singleSizer = new wxBoxSizer(wxVERTICAL); buildSingleCardsToolbar(singlePage, singleSizer); ensureSingleCardsMounted(singlePage); singleSizer->Add(singleSplitter_, 1, wxEXPAND); singlePage->SetSizer(singleSizer); - notebook_->AddPage(singlePage, "Single Cards"); + book_->AddPage(singlePage, "Single Cards"); - setCompletionPanel_ = new DigiBattle99SetCompletionPanel(notebook_, catalogStore_); + setCompletionPanel_ = new DigiBattle99SetCompletionPanel(book_, catalogStore_); setCompletionPanel_->reloadFromStore(); - notebook_->AddPage(setCompletionPanel_, "Set Completion"); + book_->AddPage(setCompletionPanel_, "Set Completion"); - root->Add(notebook_, 1, wxEXPAND); + root->Add(book_, 1, wxEXPAND | wxTOP, 5); contentPanel_->SetSizer(root); + selectTab(0); refreshToolbarIcons(paletteForTheme(config_.current().theme)); + // First mount: re-assert tab plate colors after the initial layout paint. + contentPanel_->CallAfter([this]() { + refreshTabBarTheme(paletteForTheme(config_.current().theme)); + }); } return contentPanel_; } @@ -374,6 +505,7 @@ void DigiBattle99GameView::applyTheme(const ThemePalette& palette) { if (selectedPanel_) selectedPanel_->applyTheme(palette); if (setCompletionPanel_) setCompletionPanel_->applyTheme(palette); refreshToolbarIcons(palette); + refreshTabBarTheme(palette); if (filterInput_ != nullptr) { filterInput_->SetBackgroundColour(palette.inputBg); filterInput_->SetForegroundColour(palette.inputText); diff --git a/ui_wx/src/DigiBattle99SetCompletionPanel.cpp b/ui_wx/src/DigiBattle99SetCompletionPanel.cpp index ab1ebba..65b661a 100644 --- a/ui_wx/src/DigiBattle99SetCompletionPanel.cpp +++ b/ui_wx/src/DigiBattle99SetCompletionPanel.cpp @@ -3,6 +3,7 @@ #include "ccm/services/DigiBattle99SetCompletion.hpp" #include +#include #include #include #include @@ -11,6 +12,7 @@ #include #include +#include #include namespace ccm::ui { @@ -34,6 +36,16 @@ DigiBattle99SetCompletionPanel::DigiBattle99SetCompletionPanel( : wxPanel(parent), catalogStore_(catalogStore) { palette_ = paletteForTheme(inferThemeFromWindow(this)); + auto* langRow = new wxBoxSizer(wxHORIZONTAL); + auto* langLabel = new wxStaticText(this, wxID_ANY, "Language"); + languageChoice_ = new wxChoice(this, wxID_ANY); + languageChoice_->Append("All languages"); + languageChoice_->SetSelection(0); + languageChoice_->Bind(wxEVT_CHOICE, &DigiBattle99SetCompletionPanel::onLanguageChoice, + this); + langRow->Add(langLabel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); + langRow->Add(languageChoice_, 0, wxALIGN_CENTER_VERTICAL); + book_ = new wxSimplebook(this, wxID_ANY); gridPage_ = new wxPanel(book_); @@ -72,6 +84,7 @@ DigiBattle99SetCompletionPanel::DigiBattle99SetCompletionPanel( book_->AddPage(detailPage_, "Detail"); auto* root = new wxBoxSizer(wxVERTICAL); + root->Add(langRow, 0, wxEXPAND | wxALL, 8); root->Add(book_, 1, wxEXPAND); SetSizer(root); @@ -80,11 +93,8 @@ DigiBattle99SetCompletionPanel::DigiBattle99SetCompletionPanel( void DigiBattle99SetCompletionPanel::setCollection(std::vector cards) { collection_ = std::move(cards); - if (book_->GetSelection() == 1 && !detailSetId_.empty()) { - rebuildChecklist(detailSetId_); - } else { - rebuildGrid(); - } + refreshLanguageChoice(); + rebuildCurrentView(); } void DigiBattle99SetCompletionPanel::reloadFromStore() { @@ -103,26 +113,94 @@ void DigiBattle99SetCompletionPanel::reloadFromStore() { void DigiBattle99SetCompletionPanel::applyTheme(const ThemePalette& palette) { palette_ = palette; applyThemeToWindowTree(this, palette, inferThemeFromWindow(this)); - if (book_->GetSelection() == 1 && !detailSetId_.empty()) { - rebuildChecklist(detailSetId_); - } else { - rebuildGrid(); - } + rebuildCurrentView(); } void DigiBattle99SetCompletionPanel::showGridPage() { detailSetId_.clear(); + detailSetName_.clear(); book_->SetSelection(0); } void DigiBattle99SetCompletionPanel::showChecklistPage(const std::string& setId, const std::string& setName) { detailSetId_ = setId; - detailTitle_->SetLabelText(wxString::FromUTF8(setName.c_str())); + detailSetName_ = setName; + detailTitle_->SetLabelText(wxString::FromUTF8(displaySetName(setName).c_str())); rebuildChecklist(setId); book_->SetSelection(1); } +std::string DigiBattle99SetCompletionPanel::displaySetName(const std::string& setName) const { + if (!languageFilter_.has_value()) return setName; + return setName + " (" + std::string(to_string(*languageFilter_)) + ")"; +} + +void DigiBattle99SetCompletionPanel::refreshLanguageChoice() { + const auto previous = languageFilter_; + const auto present = digiBattle99LanguagesInCollection(collection_); + + languageChoice_->Clear(); + languageChoice_->Append("All languages"); + for (const Language lang : present) { + languageChoice_->Append(wxString::FromUTF8(std::string(to_string(lang)).c_str())); + } + + int selection = 0; + languageFilter_ = std::nullopt; + if (previous.has_value()) { + for (std::size_t i = 0; i < present.size(); ++i) { + if (present[i] == *previous) { + selection = static_cast(i + 1); + languageFilter_ = previous; + break; + } + } + } + languageChoice_->SetSelection(selection); +} + +void DigiBattle99SetCompletionPanel::onLanguageChoice(wxCommandEvent& /*event*/) { + const int sel = languageChoice_->GetSelection(); + if (sel <= 0) { + languageFilter_ = std::nullopt; + } else { + const auto present = digiBattle99LanguagesInCollection(collection_); + const auto idx = static_cast(sel - 1); + if (idx < present.size()) { + languageFilter_ = present[idx]; + } else { + languageFilter_ = std::nullopt; + languageChoice_->SetSelection(0); + } + } + rebuildCurrentView(); +} + +void DigiBattle99SetCompletionPanel::rebuildCurrentView() { + if (book_->GetSelection() == 1 && !detailSetId_.empty()) { + const auto rows = + computeDigiBattle99SetCompletion(collection_, catalog_, languageFilter_); + bool stillVisible = false; + for (const auto& row : rows) { + if (row.setId == detailSetId_) { + stillVisible = true; + break; + } + } + if (!stillVisible) { + showGridPage(); + rebuildGrid(); + return; + } + detailTitle_->SetLabelText( + wxString::FromUTF8(displaySetName(detailSetName_).c_str())); + rebuildChecklist(detailSetId_); + } else { + rebuildGrid(); + } +} + void DigiBattle99SetCompletionPanel::setEmptyMessage(const wxString& message) { clearGridTiles(); emptyLabel_->SetLabelText(message); @@ -145,7 +223,8 @@ void DigiBattle99SetCompletionPanel::rebuildGrid() { return; } - const auto rows = computeDigiBattle99SetCompletion(collection_, catalog_); + const auto rows = + computeDigiBattle99SetCompletion(collection_, catalog_, languageFilter_); if (rows.empty()) { setEmptyMessage(wxString::FromUTF8( "No Digimon (Digi-Battle) sets in progress yet.\n" @@ -163,7 +242,8 @@ void DigiBattle99SetCompletionPanel::rebuildGrid() { tile->SetBackgroundColour(palette_.panelBg); auto* tileSizer = new wxBoxSizer(wxVERTICAL); - auto* nameLbl = new wxStaticText(tile, wxID_ANY, wxString::FromUTF8(row.setName.c_str())); + const std::string title = displaySetName(row.setName); + auto* nameLbl = new wxStaticText(tile, wxID_ANY, wxString::FromUTF8(title.c_str())); auto nameFont = nameLbl->GetFont(); nameFont.MakeBold(); nameLbl->SetFont(nameFont); @@ -207,16 +287,21 @@ void DigiBattle99SetCompletionPanel::rebuildGrid() { void DigiBattle99SetCompletionPanel::rebuildChecklist(const std::string& setId) { checklist_->DeleteAllItems(); - const auto entries = digiBattle99ChecklistForSet(collection_, catalog_, setId); + const auto entries = + digiBattle99ChecklistForSet(collection_, catalog_, setId, languageFilter_); const wxColour muted = mutedTextColour(palette_); + // Fixed green so owned checkmarks stay readable in both light and dark themes. + const wxColour ownedGreen(46, 160, 67); long idx = 0; for (const auto& entry : entries) { - const std::string line = entry.setNo + " — " + entry.name; + // Align names: checkmark + two spaces vs four spaces for missing cards. + const std::string line = + (entry.owned ? "✓ " : " ") + entry.setNo + " — " + entry.name; const long row = checklist_->InsertItem(idx++, wxString::FromUTF8(line.c_str())); if (row < 0) continue; if (entry.owned) { - checklist_->SetItemTextColour(row, palette_.text); + checklist_->SetItemTextColour(row, ownedGreen); } else { checklist_->SetItemTextColour(row, muted); } diff --git a/ui_wx/src/MainFrame.cpp b/ui_wx/src/MainFrame.cpp index 0c7987c..13cc9dd 100644 --- a/ui_wx/src/MainFrame.cpp +++ b/ui_wx/src/MainFrame.cpp @@ -239,8 +239,10 @@ void MainFrame::mountActiveView() { hostSizer->Add(custom, 1, wxEXPAND); contentHost_->Layout(); + // Digimon (and other hostsOwnLayout views) apply their own tree theme + // and then restore tab-strip colors; a follow-up applyThemeToWindowTree + // here would reset tab labels to panelBg and leave a dark box around text. view->applyTheme(palette); - applyThemeToWindowTree(custom, palette, ctx_.config.current().theme); return; }