convenience features (#21)

This commit is contained in:
Sebastian Dine
2026-07-24 08:56:53 +02:00
committed by GitHub
parent ab0e3c5ae2
commit 7cf25d671f
22 changed files with 351 additions and 38 deletions
+1
View File
@@ -53,6 +53,7 @@ add_library(ccm_core STATIC
src/games/pokemonjp/JapanesePokemonGameModule.cpp
src/util/FsNames.cpp
src/util/SetNoNatural.cpp
)
target_include_directories(ccm_core
@@ -23,6 +23,7 @@ struct PokemonSetCompletionProgress {
PokemonRegion region{PokemonRegion::West};
std::string setId;
std::string setName;
std::string releaseDate; // YYYY/MM/DD from owned cards; may be empty
std::size_t ownedUnique{0};
std::size_t total{0};
@@ -50,8 +51,9 @@ pokemonRegionsInCollection(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog);
// Packs where the collection owns ≥1 matching card, ordered by setName then
// region. When regionFilter is set, only that region's catalog/cards count.
// Packs where the collection owns ≥1 matching card, ordered by releaseDate
// then setName then region. When regionFilter is set, only that region's
// catalog/cards count.
[[nodiscard]] std::vector<PokemonSetCompletionProgress>
computePokemonSetCompletion(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
+14
View File
@@ -0,0 +1,14 @@
#pragma once
// Natural (alphanumeric) ordering for collector / set numbers.
// Digit runs compare as integers so "2" < "10" < "100"; non-digit runs use
// ordinary string order (e.g. "SWSH001" < "SWSH002").
#include <string_view>
namespace ccm {
// strcmp-style: <0 if a < b, 0 if equal (after natural + lex tie-break), >0 if a > b.
[[nodiscard]] int compareSetNoNatural(std::string_view a, std::string_view b) noexcept;
} // namespace ccm
+3 -1
View File
@@ -2,6 +2,7 @@
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include "ccm/util/SetNoNatural.hpp"
#include <nlohmann/json.hpp>
@@ -93,7 +94,8 @@ Result<PokemonSetCatalogPack> PokemonSetSource::parseCatalogPackFromSetDetail(
std::sort(pack.cards.begin(), pack.cards.end(),
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
if (cmp != 0) return cmp < 0;
return a.name < b.name;
});
if (pack.cards.empty()) {
@@ -2,6 +2,7 @@
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include "ccm/util/SetNoNatural.hpp"
#include <nlohmann/json.hpp>
@@ -78,7 +79,8 @@ void gapFillFromEnCatalog(PokemonSetCatalogPack& pack,
void sortPackCards(PokemonSetCatalogPack& pack) {
std::sort(pack.cards.begin(), pack.cards.end(),
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
if (cmp != 0) return cmp < 0;
return a.name < b.name;
});
}
+21 -5
View File
@@ -3,6 +3,7 @@
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include "ccm/util/SetNoNatural.hpp"
#include <algorithm>
#include <array>
@@ -13,7 +14,12 @@ namespace ccm {
namespace {
using OwnedBySet = std::unordered_map<std::string, std::unordered_set<std::string>>;
struct OwnedSetInfo {
std::unordered_set<std::string> nos;
std::string releaseDate;
};
using OwnedBySet = std::unordered_map<std::string, OwnedSetInfo>;
bool passesLanguageFilter(const PokemonCard& card, std::optional<Language> languageFilter) {
return !languageFilter.has_value() || card.language == *languageFilter;
@@ -46,7 +52,11 @@ OwnedBySet ownedSetNosBySetId(const std::vector<PokemonCard>& collection,
if (setNo.empty()) continue;
const std::string setKey =
region == PokemonRegion::West ? westSetKey(card.set.id) : card.set.id;
out[setKey].insert(setNo);
auto& info = out[setKey];
info.nos.insert(setNo);
if (info.releaseDate.empty() && !card.set.releaseDate.empty()) {
info.releaseDate = card.set.releaseDate;
}
}
return out;
}
@@ -61,20 +71,21 @@ computeForCatalog(const std::vector<PokemonCard>& collection,
std::vector<PokemonSetCompletionProgress> out;
out.reserve(owned.size());
for (const auto& [setId, ownedNos] : owned) {
for (const auto& [setId, info] : owned) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr || pack->cards.empty()) continue;
std::size_t matched = 0;
for (const auto& card : pack->cards) {
const std::string catalogNo = normalizeForRegion(region, card.setNo);
if (!catalogNo.empty() && ownedNos.count(catalogNo) != 0) ++matched;
if (!catalogNo.empty() && info.nos.count(catalogNo) != 0) ++matched;
}
PokemonSetCompletionProgress row;
row.region = region;
row.setId = pack->setId;
row.setName = pack->setName;
row.releaseDate = info.releaseDate;
row.ownedUnique = matched;
row.total = pack->cards.size();
out.push_back(std::move(row));
@@ -149,6 +160,10 @@ computePokemonSetCompletion(const std::vector<PokemonCard>& collection,
std::sort(out.begin(), out.end(),
[](const PokemonSetCompletionProgress& a,
const PokemonSetCompletionProgress& b) {
// YYYY/MM/DD lex order is chronological (CardSorter parity).
if (a.releaseDate != b.releaseDate) {
return a.releaseDate < b.releaseDate;
}
if (a.setName != b.setName) return a.setName < b.setName;
return static_cast<int>(a.region) < static_cast<int>(b.region);
});
@@ -192,7 +207,8 @@ pokemonChecklistForSet(const std::vector<PokemonCard>& collection,
std::sort(out.begin(), out.end(),
[](const PokemonChecklistEntry& a, const PokemonChecklistEntry& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
if (cmp != 0) return cmp < 0;
return a.name < b.name;
});
return out;
+69
View File
@@ -0,0 +1,69 @@
#include "ccm/util/SetNoNatural.hpp"
#include <cctype>
namespace ccm {
namespace {
[[nodiscard]] bool isAsciiDigit(char c) noexcept {
return std::isdigit(static_cast<unsigned char>(c)) != 0;
}
[[nodiscard]] int cmpChar(char a, char b) noexcept {
const auto ua = static_cast<unsigned char>(a);
const auto ub = static_cast<unsigned char>(b);
if (ua < ub) return -1;
if (ua > ub) return 1;
return 0;
}
} // namespace
int compareSetNoNatural(std::string_view a, std::string_view b) noexcept {
std::size_t i = 0;
std::size_t j = 0;
while (i < a.size() && j < b.size()) {
const bool aDigit = isAsciiDigit(a[i]);
const bool bDigit = isAsciiDigit(b[j]);
if (aDigit && bDigit) {
std::size_t aEnd = i;
while (aEnd < a.size() && isAsciiDigit(a[aEnd])) ++aEnd;
std::size_t bEnd = j;
while (bEnd < b.size() && isAsciiDigit(b[bEnd])) ++bEnd;
std::size_t aSig = i;
while (aSig < aEnd && a[aSig] == '0') ++aSig;
std::size_t bSig = j;
while (bSig < bEnd && b[bSig] == '0') ++bSig;
const std::size_t aLen = aEnd - aSig;
const std::size_t bLen = bEnd - bSig;
if (aLen != bLen) return aLen < bLen ? -1 : 1;
for (std::size_t k = 0; k < aLen; ++k) {
const int c = cmpChar(a[aSig + k], b[bSig + k]);
if (c != 0) return c;
}
i = aEnd;
j = bEnd;
continue;
}
const int c = cmpChar(a[i], b[j]);
if (c != 0) return c;
++i;
++j;
}
if (i == a.size() && j == b.size()) {
if (a == b) return 0;
return a < b ? -1 : 1;
}
return i == a.size() ? -1 : 1;
}
} // namespace ccm
+4 -3
View File
@@ -360,12 +360,13 @@ Implement the virtuals:
- `gameId()` returns `Game::<Name>`.
- `displayName()` returns `"<Display>"`.
- `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_`, and `Bind(EVT_CARD_ACTIVATED, ...)` so a double-click (or Enter on the focused row) calls `onEditCard` with `wxGetTopLevelParent(listPanel_)` as the modal owner when available. **The binding must live here**, in the typed `IGameView`, not in `MainFrame``MainFrame` only sees `IGameView` and never `<Name>Card`.
- `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_`, and `Bind(EVT_CARD_ACTIVATED, ...)` so a double-click (or Enter on the focused row) calls `onEditCard` with `wxGetTopLevelParent(listPanel_)` as the modal owner when available. Activation is raised from `BaseCardListPanel` via `CallAfter` so Edit's `ShowModal` does not run inside the list notify path. **The binding must live here**, in the typed `IGameView`, not in `MainFrame``MainFrame` only sees `IGameView` and never `<Name>Card`.
- `selectedPanel(parent)` — lazily allocates the selected panel.
- `refreshCollection()` — calls `collection_.list(Game::<Name>)`, 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 `<Name>CardEditDialog` (or pop a confirm dialog for delete), call the typed `CollectionService` to commit, and refresh on success. For Add/Edit, follow the built-in game views: if `cardEditModalIsActive()` from `ccm/ui/CardEditModalGuard.hpp`, show a themed info dialog and return; otherwise wrap `ShowModal()` with `CardEditModalGuard` so a second Add/Edit cannot stack while one card dialog is already open.
- `refreshCollection(selectId = nullopt)` — calls `collection_.list(Game::<Name>)`, handles errors with `wxMessageBox`, and pushes the new vector into `listPanel_->setCards(..., selectId)` (preserves the selected card by `id` when still present; when `selectId` is set — e.g. after Add — selects that card instead). Also re-syncs the selected panel.
- `onAddCard(parent)`, `onEditCard(parent)`, `onDeleteCard(parent)` — open the typed `<Name>CardEditDialog` (or pop a confirm dialog for delete), call the typed `CollectionService` to commit, and refresh on success. After a successful Add, call `refreshCollection(added.value())` so the new card stays selected. For Add/Edit, follow the built-in game views: if `cardEditModalIsActive()` from `ccm/ui/CardEditModalGuard.hpp`, show a themed info dialog and return; otherwise wrap `ShowModal()` with `CardEditModalGuard` so a second Add/Edit cannot stack while one card dialog is already open.
- `onUpdateSets(parent)` — calls `sets_.updateSets(Game::<Name>)`, refreshes `setsCache_`, returns a status string.
- `setFilter(filter)` — forwards to `listPanel_->setFilter(filter)`.
- `nudgeSelection(delta)` — forwards to `listPanel_->nudgeSelection(delta)` so Up/Down from the filter text box can move the table selection without stealing caret focus. Bind `wxEVT_KEY_DOWN` on the filter (MainFrame toolbar or in-game toolbar) for `WXK_UP` / `WXK_DOWN` accordingly.
- `applyTheme(palette)` — forwards to both panels' `applyTheme`.
- `updateSetsMenuLabel()` — returns `"Update <Display>"`. This is what the `Sets` menu entry shows.
+1
View File
@@ -28,6 +28,7 @@ add_executable(ccm_core_tests
digibattle99_set_completion_tests.cpp
yugioh_set_completion_tests.cpp
pokemon_set_completion_tests.cpp
set_no_natural_tests.cpp
japanese_pokemon_en_catalog_tests.cpp
japanese_pokemon_set_source_tests.cpp
japanese_pokemon_card_preview_source_tests.cpp
+52
View File
@@ -98,6 +98,34 @@ TEST_SUITE("computePokemonSetCompletion") {
CHECK(rows[1].ownedUnique == 1);
}
TEST_CASE("orders packs by releaseDate then setName") {
PokemonSetCatalog west;
PokemonSetCatalogPack newer;
newer.setId = "sv01";
newer.setName = "Scarlet & Violet";
newer.cards = {{"1", "Sprigatito"}};
PokemonSetCatalogPack older;
older.setId = "base1";
older.setName = "Zoo Set"; // would sort after Scarlet by name
older.cards = {{"4", "Charizard"}};
west.packs.push_back(std::move(newer));
west.packs.push_back(std::move(older));
PokemonSetCatalog emptyAsia;
PokemonCard base = makeOwned(PokemonRegion::West, "base1", "4");
base.set.releaseDate = "1999/01/09";
PokemonCard sv = makeOwned(PokemonRegion::West, "sv01", "1");
sv.id = 2;
sv.set.releaseDate = "2023/03/31";
const auto rows = computePokemonSetCompletion({base, sv}, west, emptyAsia);
REQUIRE(rows.size() == 2);
CHECK(rows[0].setId == "base1");
CHECK(rows[0].releaseDate == "1999/01/09");
CHECK(rows[1].setId == "sv01");
CHECK(rows[1].releaseDate == "2023/03/31");
}
TEST_CASE("region filter isolates catalogs") {
const auto west = westCatalog();
const auto asia = asiaCatalog();
@@ -204,6 +232,30 @@ TEST_SUITE("pokemonChecklistForSet") {
CHECK(list[2].owned == false);
}
TEST_CASE("orders unpadded set numbers numerically") {
PokemonSetCatalog west;
PokemonSetCatalogPack pack;
pack.setId = "base1";
pack.setName = "Base";
// Insert out of order / in lex-favoring order to prove we re-sort.
pack.cards = {
{"100", "Lightning Energy"},
{"1", "Alakazam"},
{"10", "Mewtwo"},
{"2", "Blastoise"},
};
west.packs.push_back(std::move(pack));
PokemonSetCatalog emptyAsia;
const auto list = pokemonChecklistForSet({}, west, emptyAsia,
PokemonRegion::West, "base1");
REQUIRE(list.size() == 4);
CHECK(list[0].setNo == "1");
CHECK(list[1].setNo == "2");
CHECK(list[2].setNo == "10");
CHECK(list[3].setNo == "100");
}
TEST_CASE("asia card does not mark west checklist") {
const auto west = westCatalog();
const auto asia = asiaCatalog();
+40
View File
@@ -0,0 +1,40 @@
#include <doctest/doctest.h>
#include "ccm/util/SetNoNatural.hpp"
#include <algorithm>
#include <string>
#include <vector>
using ccm::compareSetNoNatural;
TEST_SUITE("compareSetNoNatural") {
TEST_CASE("orders pure digits numerically") {
CHECK(compareSetNoNatural("1", "2") < 0);
CHECK(compareSetNoNatural("2", "10") < 0);
CHECK(compareSetNoNatural("10", "100") < 0);
CHECK(compareSetNoNatural("2", "1") > 0);
CHECK(compareSetNoNatural("10", "2") > 0);
}
TEST_CASE("leading zeros tie numerically then lex") {
CHECK(compareSetNoNatural("001", "1") != 0);
CHECK(compareSetNoNatural("1", "001") > 0); // "001" < "1" lexicographically
CHECK(compareSetNoNatural("001", "002") < 0);
CHECK(compareSetNoNatural("001", "001") == 0);
}
TEST_CASE("alpha prefix then numeric run") {
CHECK(compareSetNoNatural("SWSH001", "SWSH002") < 0);
CHECK(compareSetNoNatural("SWSH10", "SWSH2") > 0);
CHECK(compareSetNoNatural("A10", "B2") < 0);
}
TEST_CASE("sorts base-set style list into numeric order") {
std::vector<std::string> nos{"1", "10", "100", "2", "20", "3"};
std::sort(nos.begin(), nos.end(), [](const std::string& a, const std::string& b) {
return compareSetNoNatural(a, b) < 0;
});
CHECK(nos == std::vector<std::string>{"1", "2", "3", "10", "20", "100"});
}
}
+59 -7
View File
@@ -80,16 +80,23 @@ public:
using card_type = TCard;
using sort_column_type = TSortColumn;
// Replace the displayed rows. Selection is reset (the panel will pick
// the first row on the next idle turn — see rebuildRows()).
void setCards(std::vector<TCard> cards) {
// Replace the displayed rows. When preferSelectId is set, selects that
// card if present (used after Add). Otherwise preserves the previously
// selected card by id when still present; the first-row CallAfter path in
// rebuildRows() runs only when there was no prior selection (startup).
void setCards(std::vector<TCard> cards,
std::optional<std::uint32_t> preferSelectId = std::nullopt) {
std::optional<std::uint32_t> keepId = preferSelectId;
if (!keepId) {
if (auto sel = selected()) keepId = sel->id;
}
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();
rebuildRows(keepId);
if (!autoSizedOnce_ && !cards_.empty()) {
autoSizeAllColumns();
autoSizedOnce_ = true;
@@ -143,6 +150,30 @@ public:
list_->SetFocus();
}
// Move the selection by `delta` rows (+1 / -1). Used when Up/Down are
// pressed while focus is on the filter box. Clamps to the visible range;
// leaves list HWND focus alone so the caret can stay in the filter.
void nudgeSelection(int delta) {
if (list_ == nullptr || list_->GetItemCount() <= 0 || delta == 0) return;
long row = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
if (row < 0) row = 0;
const long count = list_->GetItemCount();
long next = row + delta;
if (next < 0) next = 0;
if (next >= count) next = count - 1;
if (next == row) {
list_->EnsureVisible(next);
return;
}
suppressListFocus_ = true;
list_->SetItemState(row, 0, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
list_->SetItemState(next,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
list_->EnsureVisible(next);
suppressListFocus_ = false;
}
protected:
// Column descriptor types -------------------------------------------------
@@ -330,6 +361,17 @@ private:
addText(textCols_.back().label, textCols_.back().width, noteCol);
headerRow_->SetSizer(s);
// Header is mouse-only (sort / resize). Keep it out of the tab order so
// Up/Down after a header click still drive the list, not wx focus travel.
headerRow_->SetCanFocus(false);
for (wxWindow* cell : headerCells_) {
if (cell == nullptr) continue;
cell->SetCanFocus(false);
for (wxWindow* child : cell->GetChildren()) {
if (child != nullptr) child->SetCanFocus(false);
}
}
}
// ----- header drag-resize / sort hit-test ---------------------------------
@@ -486,6 +528,7 @@ private:
sortBy(*sortCol, ascending);
rebuildRows(keepId);
if (list_ != nullptr) list_->SetFocus();
}
// ----- cached icon bitmaps for NM_CUSTOMDRAW -----------------------------
@@ -644,15 +687,22 @@ private:
// no per-row icon swap is required here.
(void)event;
if (inRebuild_) return;
// Row click / native arrow keys: keep HWND focus on the list. Filter
// nudge sets suppressListFocus_ so the caret stays in the text box.
if (!suppressListFocus_ && list_ != nullptr) list_->SetFocus();
notifySelectionChanged();
}
void onListItemActivated(wxListEvent& event) {
(void)event;
if (inRebuild_) return;
wxCommandEvent ev(EVT_CARD_ACTIVATED, GetId());
ev.SetEventObject(this);
ProcessWindowEvent(ev);
// Defer so ShowModal (Edit) does not run inside the list notify path.
CallAfter([this]() {
if (inRebuild_) return;
wxCommandEvent ev(EVT_CARD_ACTIVATED, GetId());
ev.SetEventObject(this);
ProcessWindowEvent(ev);
});
}
// ----- members ----------------------------------------------------------
@@ -682,6 +732,8 @@ private:
// Rebuild guard - see ui_wx/AGENTS.md for the burst-suppression rationale.
bool inRebuild_{false};
// When true, onSelectionChanged skips list_->SetFocus (filter Up/Down nudge).
bool suppressListFocus_{false};
std::map<TSortColumn, bool> nextDirByCol_;
@@ -50,12 +50,13 @@ public:
}
[[nodiscard]] bool hostsOwnLayout() const noexcept override { return true; }
void refreshCollection() override;
void refreshCollection(std::optional<std::uint32_t> selectId = std::nullopt) 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 nudgeSelection(int delta) override;
void applyTheme(const ThemePalette& palette) override;
[[nodiscard]] std::string updateSetsMenuLabel() const override {
return "Update Digimon (Digi-Battle)";
+10 -3
View File
@@ -13,6 +13,8 @@
#include "ccm/domain/Set.hpp"
#include "ccm/ui/Theme.hpp"
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
@@ -54,9 +56,10 @@ public:
// list/selected panels parented onto MainFrame's shared splitter.
[[nodiscard]] virtual bool hostsOwnLayout() const noexcept { return false; }
// Reload the active collection from disk and refresh the panels. The
// selected card is preserved when possible.
virtual void refreshCollection() = 0;
// Reload the active collection from disk and refresh the panels. When
// selectId is set, that card is selected if present (e.g. after Add);
// otherwise the previously selected card is preserved when possible.
virtual void refreshCollection(std::optional<std::uint32_t> selectId = std::nullopt) = 0;
// Toolbar actions. `parentWindow` is the dialog owner for any modal we
// open (typically the `MainFrame`).
@@ -71,6 +74,10 @@ public:
// Forwarded by `MainFrame` whenever the filter input changes.
virtual void setFilter(std::string_view filter) = 0;
// Move the card-list selection by `delta` rows (+1 / -1). Used when Up/Down
// are pressed while the filter text box has focus.
virtual void nudgeSelection(int delta) = 0;
// Apply the active palette to all panels owned by this view.
virtual void applyTheme(const ThemePalette& palette) = 0;
+2 -1
View File
@@ -38,12 +38,13 @@ public:
wxPanel* listPanel(wxWindow* parent) override;
wxPanel* selectedPanel(wxWindow* parent) override;
void refreshCollection() override;
void refreshCollection(std::optional<std::uint32_t> selectId = std::nullopt) 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 nudgeSelection(int delta) override;
void applyTheme(const ThemePalette& palette) override;
[[nodiscard]] std::string updateSetsMenuLabel() const override { return "Update Magic"; }
+2 -1
View File
@@ -55,12 +55,13 @@ public:
}
[[nodiscard]] bool hostsOwnLayout() const noexcept override { return true; }
void refreshCollection() override;
void refreshCollection(std::optional<std::uint32_t> selectId = std::nullopt) 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 nudgeSelection(int delta) override;
void applyTheme(const ThemePalette& palette) override;
[[nodiscard]] std::string updateSetsMenuLabel() const override { return "Update Pokemon"; }
+2 -1
View File
@@ -50,12 +50,13 @@ public:
}
[[nodiscard]] bool hostsOwnLayout() const noexcept override { return true; }
void refreshCollection() override;
void refreshCollection(std::optional<std::uint32_t> selectId = std::nullopt) 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 nudgeSelection(int delta) override;
void applyTheme(const ThemePalette& palette) override;
[[nodiscard]] std::string updateSetsMenuLabel() const override { return "Update Yu-Gi-Oh!"; }
+15 -3
View File
@@ -128,6 +128,14 @@ void DigiBattle99GameView::buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer*
if (filterInput_ == nullptr) return;
setFilter(filterInput_->GetValue().ToStdString(wxConvUTF8));
});
filterInput_->Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& ev) {
const int code = ev.GetKeyCode();
if (code == WXK_UP || code == WXK_DOWN) {
nudgeSelection(code == WXK_UP ? -1 : 1);
return;
}
ev.Skip();
});
}
void DigiBattle99GameView::refreshToolbarIcons(const ThemePalette& palette) {
@@ -308,7 +316,7 @@ wxPanel* DigiBattle99GameView::selectedPanel(wxWindow* parent) {
return selectedPanel_;
}
void DigiBattle99GameView::refreshCollection() {
void DigiBattle99GameView::refreshCollection(std::optional<std::uint32_t> selectId) {
// Ensure the Digimon host (and list panel) exist even when MainFrame mounts
// via contentPanel before an explicit listPanel call.
if (contentPanel_ == nullptr && listPanel_ == nullptr) return;
@@ -323,7 +331,7 @@ void DigiBattle99GameView::refreshCollection() {
}
auto cards = std::move(loaded).value();
if (listPanel_ != nullptr) {
listPanel_->setCards(cards);
listPanel_->setCards(cards, selectId);
listPanel_->activateSelection();
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
}
@@ -387,7 +395,7 @@ void DigiBattle99GameView::onAddCard(wxWindow* parentWindow) {
"Card added, but image rename to ID-prefixed format failed: " + normalized.error(),
"Warning", wxOK | wxICON_WARNING);
}
refreshCollection();
refreshCollection(added.value());
}
void DigiBattle99GameView::onEditCard(wxWindow* parentWindow) {
@@ -499,6 +507,10 @@ void DigiBattle99GameView::setFilter(std::string_view filter) {
if (listPanel_) listPanel_->setFilter(filter);
}
void DigiBattle99GameView::nudgeSelection(int delta) {
if (listPanel_) listPanel_->nudgeSelection(delta);
}
void DigiBattle99GameView::applyTheme(const ThemePalette& palette) {
if (contentPanel_) applyThemeToWindowTree(contentPanel_, palette, config_.current().theme);
if (listPanel_) listPanel_->applyTheme(palette);
+7 -3
View File
@@ -71,7 +71,7 @@ wxPanel* MagicGameView::selectedPanel(wxWindow* parent) {
return selectedPanel_;
}
void MagicGameView::refreshCollection() {
void MagicGameView::refreshCollection(std::optional<std::uint32_t> selectId) {
if (listPanel_ == nullptr) return;
auto loaded = collection_.list(Game::Magic);
if (!loaded) {
@@ -79,7 +79,7 @@ void MagicGameView::refreshCollection() {
"Error", wxOK | wxICON_ERROR);
return;
}
listPanel_->setCards(std::move(loaded).value());
listPanel_->setCards(std::move(loaded).value(), selectId);
listPanel_->activateSelection();
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
}
@@ -134,7 +134,7 @@ void MagicGameView::onAddCard(wxWindow* parentWindow) {
showThemedMessageDialog(parentWindow, "Card added, but image rename to ID-prefixed format failed: " + normalized.error(),
"Warning", wxOK | wxICON_WARNING);
}
refreshCollection();
refreshCollection(added.value());
}
void MagicGameView::onEditCard(wxWindow* parentWindow) {
@@ -200,6 +200,10 @@ void MagicGameView::setFilter(std::string_view filter) {
if (listPanel_) listPanel_->setFilter(filter);
}
void MagicGameView::nudgeSelection(int delta) {
if (listPanel_) listPanel_->nudgeSelection(delta);
}
void MagicGameView::applyTheme(const ThemePalette& palette) {
if (listPanel_) listPanel_->applyTheme(palette);
if (selectedPanel_) selectedPanel_->applyTheme(palette);
+10
View File
@@ -185,6 +185,16 @@ void MainFrame::buildLayout() {
view->setFilter(filterInput_->GetValue().ToStdString());
}
});
filterInput_->Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& ev) {
const int code = ev.GetKeyCode();
if (code == WXK_UP || code == WXK_DOWN) {
if (auto* view = activeView()) {
view->nudgeSelection(code == WXK_UP ? -1 : 1);
}
return;
}
ev.Skip();
});
// Selection changes are handled per-view (each IGameView binds
// EVT_CARD_SELECTED on its own typed list panel and pushes the typed
+15 -3
View File
@@ -136,6 +136,14 @@ void PokemonGameView::buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* page
if (filterInput_ == nullptr) return;
setFilter(filterInput_->GetValue().ToStdString(wxConvUTF8));
});
filterInput_->Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& ev) {
const int code = ev.GetKeyCode();
if (code == WXK_UP || code == WXK_DOWN) {
nudgeSelection(code == WXK_UP ? -1 : 1);
return;
}
ev.Skip();
});
}
void PokemonGameView::refreshToolbarIcons(const ThemePalette& palette) {
@@ -310,7 +318,7 @@ wxPanel* PokemonGameView::selectedPanel(wxWindow* parent) {
return selectedPanel_;
}
void PokemonGameView::refreshCollection() {
void PokemonGameView::refreshCollection(std::optional<std::uint32_t> selectId) {
if (contentPanel_ == nullptr && listPanel_ == nullptr) return;
auto loaded = collection_.list(Game::Pokemon);
@@ -321,7 +329,7 @@ void PokemonGameView::refreshCollection() {
}
auto cards = std::move(loaded).value();
if (listPanel_ != nullptr) {
listPanel_->setCards(cards);
listPanel_->setCards(cards, selectId);
listPanel_->activateSelection();
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
}
@@ -389,7 +397,7 @@ void PokemonGameView::onAddCard(wxWindow* parentWindow) {
showThemedMessageDialog(parentWindow, "Card added, but image rename to ID-prefixed format failed: " + normalized.error(),
"Warning", wxOK | wxICON_WARNING);
}
refreshCollection();
refreshCollection(added.value());
}
void PokemonGameView::onEditCard(wxWindow* parentWindow) {
@@ -582,6 +590,10 @@ void PokemonGameView::setFilter(std::string_view filter) {
if (listPanel_) listPanel_->setFilter(filter);
}
void PokemonGameView::nudgeSelection(int delta) {
if (listPanel_) listPanel_->nudgeSelection(delta);
}
void PokemonGameView::applyTheme(const ThemePalette& palette) {
if (contentPanel_) applyThemeToWindowTree(contentPanel_, palette, config_.current().theme);
if (listPanel_) listPanel_->applyTheme(palette);
+15 -3
View File
@@ -134,6 +134,14 @@ void YuGiOhGameView::buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageS
if (filterInput_ == nullptr) return;
setFilter(filterInput_->GetValue().ToStdString(wxConvUTF8));
});
filterInput_->Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& ev) {
const int code = ev.GetKeyCode();
if (code == WXK_UP || code == WXK_DOWN) {
nudgeSelection(code == WXK_UP ? -1 : 1);
return;
}
ev.Skip();
});
}
void YuGiOhGameView::refreshToolbarIcons(const ThemePalette& palette) {
@@ -308,7 +316,7 @@ wxPanel* YuGiOhGameView::selectedPanel(wxWindow* parent) {
return selectedPanel_;
}
void YuGiOhGameView::refreshCollection() {
void YuGiOhGameView::refreshCollection(std::optional<std::uint32_t> selectId) {
if (contentPanel_ == nullptr && listPanel_ == nullptr) return;
auto loaded = collection_.list(Game::YuGiOh);
@@ -319,7 +327,7 @@ void YuGiOhGameView::refreshCollection() {
}
auto cards = std::move(loaded).value();
if (listPanel_ != nullptr) {
listPanel_->setCards(cards);
listPanel_->setCards(cards, selectId);
listPanel_->activateSelection();
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
}
@@ -391,7 +399,7 @@ void YuGiOhGameView::onAddCard(wxWindow* parentWindow) {
"Card added, but image rename to ID-prefixed format failed: " + normalized.error(),
"Warning", wxOK | wxICON_WARNING);
}
refreshCollection();
refreshCollection(added.value());
}
void YuGiOhGameView::onEditCard(wxWindow* parentWindow) {
@@ -505,6 +513,10 @@ void YuGiOhGameView::setFilter(std::string_view filter) {
if (listPanel_) listPanel_->setFilter(filter);
}
void YuGiOhGameView::nudgeSelection(int delta) {
if (listPanel_) listPanel_->nudgeSelection(delta);
}
void YuGiOhGameView::applyTheme(const ThemePalette& palette) {
if (contentPanel_) applyThemeToWindowTree(contentPanel_, palette, config_.current().theme);
if (listPanel_) listPanel_->applyTheme(palette);