mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 22:01:12 +00:00
convenience features (#21)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"});
|
||||
}
|
||||
}
|
||||
@@ -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)";
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"; }
|
||||
|
||||
|
||||
@@ -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"; }
|
||||
|
||||
|
||||
@@ -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!"; }
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user