diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index c60676e..f89471a 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -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 diff --git a/core/include/ccm/services/PokemonSetCompletion.hpp b/core/include/ccm/services/PokemonSetCompletion.hpp index 56a551d..e8a02ea 100644 --- a/core/include/ccm/services/PokemonSetCompletion.hpp +++ b/core/include/ccm/services/PokemonSetCompletion.hpp @@ -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& 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 computePokemonSetCompletion(const std::vector& collection, const PokemonSetCatalog& westCatalog, diff --git a/core/include/ccm/util/SetNoNatural.hpp b/core/include/ccm/util/SetNoNatural.hpp new file mode 100644 index 0000000..33e3a4e --- /dev/null +++ b/core/include/ccm/util/SetNoNatural.hpp @@ -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 + +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 diff --git a/core/src/games/pokemon/PokemonSetSource.cpp b/core/src/games/pokemon/PokemonSetSource.cpp index d50bb51..d71c4cd 100644 --- a/core/src/games/pokemon/PokemonSetSource.cpp +++ b/core/src/games/pokemon/PokemonSetSource.cpp @@ -2,6 +2,7 @@ #include "ccm/games/pokemon/PokemonCardPreviewSource.hpp" #include "ccm/util/Rfc3986.hpp" +#include "ccm/util/SetNoNatural.hpp" #include @@ -93,7 +94,8 @@ Result 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()) { diff --git a/core/src/games/pokemonjp/JapanesePokemonSetSource.cpp b/core/src/games/pokemonjp/JapanesePokemonSetSource.cpp index 27bcca9..c078409 100644 --- a/core/src/games/pokemonjp/JapanesePokemonSetSource.cpp +++ b/core/src/games/pokemonjp/JapanesePokemonSetSource.cpp @@ -2,6 +2,7 @@ #include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp" #include "ccm/util/Rfc3986.hpp" +#include "ccm/util/SetNoNatural.hpp" #include @@ -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; }); } diff --git a/core/src/services/PokemonSetCompletion.cpp b/core/src/services/PokemonSetCompletion.cpp index 6ee7589..62af69f 100644 --- a/core/src/services/PokemonSetCompletion.cpp +++ b/core/src/services/PokemonSetCompletion.cpp @@ -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 #include @@ -13,7 +14,12 @@ namespace ccm { namespace { -using OwnedBySet = std::unordered_map>; +struct OwnedSetInfo { + std::unordered_set nos; + std::string releaseDate; +}; + +using OwnedBySet = std::unordered_map; bool passesLanguageFilter(const PokemonCard& card, std::optional languageFilter) { return !languageFilter.has_value() || card.language == *languageFilter; @@ -46,7 +52,11 @@ OwnedBySet ownedSetNosBySetId(const std::vector& 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& collection, std::vector 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& 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(a.region) < static_cast(b.region); }); @@ -192,7 +207,8 @@ pokemonChecklistForSet(const std::vector& 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; diff --git a/core/src/util/SetNoNatural.cpp b/core/src/util/SetNoNatural.cpp new file mode 100644 index 0000000..143d552 --- /dev/null +++ b/core/src/util/SetNoNatural.cpp @@ -0,0 +1,69 @@ +#include "ccm/util/SetNoNatural.hpp" + +#include + +namespace ccm { + +namespace { + +[[nodiscard]] bool isAsciiDigit(char c) noexcept { + return std::isdigit(static_cast(c)) != 0; +} + +[[nodiscard]] int cmpChar(char a, char b) noexcept { + const auto ua = static_cast(a); + const auto ub = static_cast(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 diff --git a/docs/adding-a-new-game.md b/docs/adding-a-new-game.md index 4efe3a4..ddd7307 100644 --- a/docs/adding-a-new-game.md +++ b/docs/adding-a-new-game.md @@ -360,12 +360,13 @@ 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_`, 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 `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 `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. 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::)`, 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 `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::)`, 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 "`. This is what the `Sets` menu entry shows. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 78e95f2..4166afa 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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 diff --git a/tests/pokemon_set_completion_tests.cpp b/tests/pokemon_set_completion_tests.cpp index 5b125f2..baf1628 100644 --- a/tests/pokemon_set_completion_tests.cpp +++ b/tests/pokemon_set_completion_tests.cpp @@ -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(); diff --git a/tests/set_no_natural_tests.cpp b/tests/set_no_natural_tests.cpp new file mode 100644 index 0000000..6a737cb --- /dev/null +++ b/tests/set_no_natural_tests.cpp @@ -0,0 +1,40 @@ +#include + +#include "ccm/util/SetNoNatural.hpp" + +#include +#include +#include + +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 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{"1", "2", "3", "10", "20", "100"}); + } +} diff --git a/ui_wx/include/ccm/ui/BaseCardListPanel.hpp b/ui_wx/include/ccm/ui/BaseCardListPanel.hpp index f7442b3..6cbaac5 100644 --- a/ui_wx/include/ccm/ui/BaseCardListPanel.hpp +++ b/ui_wx/include/ccm/ui/BaseCardListPanel.hpp @@ -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 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 cards, + std::optional preferSelectId = std::nullopt) { + std::optional 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 nextDirByCol_; diff --git a/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp b/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp index dffafa2..2640d36 100644 --- a/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp +++ b/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp @@ -50,12 +50,13 @@ public: } [[nodiscard]] bool hostsOwnLayout() const noexcept override { return true; } - void refreshCollection() override; + void refreshCollection(std::optional 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)"; diff --git a/ui_wx/include/ccm/ui/IGameView.hpp b/ui_wx/include/ccm/ui/IGameView.hpp index d7efad5..94671a0 100644 --- a/ui_wx/include/ccm/ui/IGameView.hpp +++ b/ui_wx/include/ccm/ui/IGameView.hpp @@ -13,6 +13,8 @@ #include "ccm/domain/Set.hpp" #include "ccm/ui/Theme.hpp" +#include +#include #include #include #include @@ -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 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; diff --git a/ui_wx/include/ccm/ui/MagicGameView.hpp b/ui_wx/include/ccm/ui/MagicGameView.hpp index fc3d7bb..39fc821 100644 --- a/ui_wx/include/ccm/ui/MagicGameView.hpp +++ b/ui_wx/include/ccm/ui/MagicGameView.hpp @@ -38,12 +38,13 @@ public: wxPanel* listPanel(wxWindow* parent) override; wxPanel* selectedPanel(wxWindow* parent) override; - void refreshCollection() override; + void refreshCollection(std::optional 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"; } diff --git a/ui_wx/include/ccm/ui/PokemonGameView.hpp b/ui_wx/include/ccm/ui/PokemonGameView.hpp index 069c3c7..6fb5295 100644 --- a/ui_wx/include/ccm/ui/PokemonGameView.hpp +++ b/ui_wx/include/ccm/ui/PokemonGameView.hpp @@ -55,12 +55,13 @@ public: } [[nodiscard]] bool hostsOwnLayout() const noexcept override { return true; } - void refreshCollection() override; + void refreshCollection(std::optional 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"; } diff --git a/ui_wx/include/ccm/ui/YuGiOhGameView.hpp b/ui_wx/include/ccm/ui/YuGiOhGameView.hpp index dd40a59..f8940c4 100644 --- a/ui_wx/include/ccm/ui/YuGiOhGameView.hpp +++ b/ui_wx/include/ccm/ui/YuGiOhGameView.hpp @@ -50,12 +50,13 @@ public: } [[nodiscard]] bool hostsOwnLayout() const noexcept override { return true; } - void refreshCollection() override; + void refreshCollection(std::optional 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!"; } diff --git a/ui_wx/src/DigiBattle99GameView.cpp b/ui_wx/src/DigiBattle99GameView.cpp index 4548046..fae55f7 100644 --- a/ui_wx/src/DigiBattle99GameView.cpp +++ b/ui_wx/src/DigiBattle99GameView.cpp @@ -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 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); diff --git a/ui_wx/src/MagicGameView.cpp b/ui_wx/src/MagicGameView.cpp index a9f04ae..5c1bb94 100644 --- a/ui_wx/src/MagicGameView.cpp +++ b/ui_wx/src/MagicGameView.cpp @@ -71,7 +71,7 @@ wxPanel* MagicGameView::selectedPanel(wxWindow* parent) { return selectedPanel_; } -void MagicGameView::refreshCollection() { +void MagicGameView::refreshCollection(std::optional 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); diff --git a/ui_wx/src/MainFrame.cpp b/ui_wx/src/MainFrame.cpp index 13cc9dd..96a17bd 100644 --- a/ui_wx/src/MainFrame.cpp +++ b/ui_wx/src/MainFrame.cpp @@ -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 diff --git a/ui_wx/src/PokemonGameView.cpp b/ui_wx/src/PokemonGameView.cpp index b4a8369..2a14dc3 100644 --- a/ui_wx/src/PokemonGameView.cpp +++ b/ui_wx/src/PokemonGameView.cpp @@ -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 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); diff --git a/ui_wx/src/YuGiOhGameView.cpp b/ui_wx/src/YuGiOhGameView.cpp index ba3e257..543b602 100644 --- a/ui_wx/src/YuGiOhGameView.cpp +++ b/ui_wx/src/YuGiOhGameView.cpp @@ -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 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);