Minor: Additional functions (#25)

* several functions. fixes #1 and #2

* multi selection functionality
This commit is contained in:
Sebastian Dine
2026-08-03 08:35:38 +02:00
committed by GitHub
parent 2eb7c59f78
commit d3b4762b76
60 changed files with 1859 additions and 172 deletions
@@ -22,6 +22,7 @@
#include "ccm/services/SetService.hpp"
#include "ccm/ui/ImageViewerDialog.hpp"
#include "ccm/ui/Theme.hpp"
#include "ccm/util/CardLookupDetect.hpp"
#include <wx/arrstr.h>
#include <wx/button.h>
@@ -136,6 +137,26 @@ protected:
// controls cannot outlive the lookup identity.
virtual void onCardLookupContextChanged() {}
// Bidirectional Set # Auto detect: track which of Name / Set # the user
// last typed so a second detect uses that field as the lookup key.
void markNameLookupEdited() { lastLookupEditField_ = CardLookupEditField::Name; }
// Subclasses bind Set # `wxEVT_TEXT` to this (or call it from their handler).
// Also clears print-variant caches via `onCardLookupContextChanged`.
void markSetNoLookupEdited() {
lastLookupEditField_ = CardLookupEditField::SetNo;
onCardLookupContextChanged();
}
[[nodiscard]] CardLookupEditField lastLookupEditField() const noexcept {
return lastLookupEditField_;
}
// `nameEmpty` / `setNoEmpty` must already be trimmed/normalized by the caller.
[[nodiscard]] bool shouldDetectBySetNo(bool nameEmpty, bool setNoEmpty) const noexcept {
return preferDetectBySetNo(nameEmpty, setNoEmpty, lastLookupEditField_);
}
// Extra validation after name/set checks and writeFromControls(). Return
// false to block OK (subclass should show its own themed dialog).
[[nodiscard]] virtual bool validateExtraFields() { return true; }
@@ -210,6 +231,7 @@ private:
nameCtrl_ = new wxTextCtrl(this, wxID_ANY, wxString::FromUTF8(card_.name.c_str()));
nameCtrl_->Bind(wxEVT_TEXT, [this](wxCommandEvent& ev) {
markNameLookupEdited();
onCardLookupContextChanged();
ev.Skip();
});
@@ -626,6 +648,7 @@ private:
const std::vector<Set>* preloadedSets_{nullptr};
wxTextCtrl* nameCtrl_{nullptr};
CardLookupEditField lastLookupEditField_{CardLookupEditField::None};
wxComboBox* setCombo_{nullptr};
wxSpinCtrl* amountCtrl_{nullptr};
wxChoice* languageChoice_{nullptr};
+164 -37
View File
@@ -40,8 +40,10 @@
#include "ccm/ui/Theme.hpp"
#include <wx/bitmap.h>
#include <wx/clipbrd.h>
#include <wx/colour.h>
#include <wx/cursor.h>
#include <wx/dataobj.h>
#include <wx/event.h>
#include <wx/image.h>
#include <wx/listctrl.h>
@@ -59,6 +61,7 @@
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
@@ -74,6 +77,10 @@ wxDECLARE_EVENT(EVT_CARD_SELECTED, wxCommandEvent);
// `IGameView` implementations bind this to open Edit for `selected()`.
wxDECLARE_EVENT(EVT_CARD_ACTIVATED, wxCommandEvent);
// Raised when the list wants a short status-bar note (e.g. clipboard copy).
// `event.GetString()` is the message; MainFrame shows it in the bottom strip.
wxDECLARE_EVENT(EVT_UI_STATUS, wxCommandEvent);
template <typename TCard, typename TSortColumn>
class BaseCardListPanel : public wxPanel {
public:
@@ -81,14 +88,18 @@ public:
using sort_column_type = TSortColumn;
// 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).
// card exclusively if present (used after Add). Otherwise preserves the
// previously selected card ids 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;
std::optional<std::vector<std::uint32_t>> keepIds;
if (preferSelectId) {
keepIds = std::vector<std::uint32_t>{*preferSelectId};
} else {
auto ids = selectedIds();
if (!ids.empty()) keepIds = std::move(ids);
}
cards_ = std::move(cards);
// Drop sort state when the underlying data is replaced - the indicator
@@ -96,7 +107,7 @@ public:
// wxListCtrl keeps the indicator across DeleteAllItems().
nextDirByCol_.clear();
list_->RemoveSortIndicator();
rebuildRows(keepId);
rebuildRows(keepIds);
if (!autoSizedOnce_ && !cards_.empty()) {
autoSizeAllColumns();
autoSizedOnce_ = true;
@@ -104,16 +115,17 @@ public:
}
// Update the filter string and rebuild the visible rows in place. The
// panel preserves the previously-selected card across the rebuild when
// it still matches the new filter; otherwise the first remaining row is
// panel preserves previously-selected cards across the rebuild when they
// still match the new filter; otherwise the first remaining row is
// selected, or none if the filter excluded everything. A single
// EVT_CARD_SELECTED is emitted afterwards so the parent re-syncs.
void setFilter(std::string_view filter) {
if (filter_ == filter) return;
filter_.assign(filter);
std::optional<std::uint32_t> keepId;
if (auto sel = selected()) keepId = sel->id;
rebuildRows(keepId);
auto ids = selectedIds();
std::optional<std::vector<std::uint32_t>> keepIds;
if (!ids.empty()) keepIds = std::move(ids);
rebuildRows(keepIds);
}
void applyTheme(const ThemePalette& palette) {
@@ -123,22 +135,52 @@ public:
SetForegroundColour(palette.text);
rebuildIconBitmaps(palette.inputText, wxColour(255, 255, 255));
refreshHeaderTheme(palette);
std::optional<std::uint32_t> keepId;
if (auto sel = selected()) keepId = sel->id;
rebuildRows(keepId);
auto ids = selectedIds();
std::optional<std::vector<std::uint32_t>> keepIds;
if (!ids.empty()) keepIds = std::move(ids);
rebuildRows(keepIds);
Refresh();
}
[[nodiscard]] const std::vector<TCard>& cards() const noexcept { return cards_; }
[[nodiscard]] const std::string& filter() const noexcept { return filter_; }
// First selected card (detail panel / single-edit primary).
[[nodiscard]] std::optional<TCard> selected() const {
const long sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
if (const TCard* c = cardForRow(sel)) return *c;
return std::nullopt;
}
[[nodiscard]] std::size_t selectedCount() const {
if (list_ == nullptr) return 0;
std::size_t n = 0;
long row = -1;
while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
++n;
}
return n;
}
[[nodiscard]] std::vector<TCard> selectedCards() const {
std::vector<TCard> out;
if (list_ == nullptr) return out;
long row = -1;
while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
if (const TCard* c = cardForRow(row)) out.push_back(*c);
}
return out;
}
[[nodiscard]] std::vector<std::uint32_t> selectedIds() const {
std::vector<std::uint32_t> out;
if (list_ == nullptr) return out;
long row = -1;
while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
if (const TCard* c = cardForRow(row)) out.push_back(c->id);
}
return out;
}
// Ensure the selected row is actively focused so Windows uses the active
// highlight color (blue in light mode), keeping selected-row icons legible.
// Ensure the first selected row is actively focused so Windows uses the
// active highlight color (blue in light mode), keeping selected-row icons
// legible. Does not clear a multi-selection.
void activateSelection() {
if (list_ == nullptr || list_->GetItemCount() <= 0) return;
long row = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
@@ -151,8 +193,9 @@ public:
}
// 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.
// pressed while focus is on the filter box. Collapses any multi-selection
// to a single row. 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);
@@ -161,12 +204,12 @@ public:
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);
// Clear every selected row so filter nudge is always single-select.
long sel = -1;
while ((sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
list_->SetItemState(sel, 0, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
}
list_->SetItemState(next,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
@@ -215,8 +258,10 @@ protected:
// Subclass calls this once from its constructor body (after virtual hooks
// are reachable) to wire up columns + the header row + custom-draw hooks.
void buildLayout() {
// Multi-select: native Ctrl (toggle) and Shift (range) without
// wxLC_SINGLE_SEL. Set-completion tables keep single-select separately.
list_ = new IconListCtrl(this, wxID_ANY, wxDefaultPosition, wxDefaultSize,
wxLC_REPORT | wxLC_SINGLE_SEL | wxLC_NO_HEADER);
wxLC_REPORT | wxLC_NO_HEADER);
textCols_ = declareTextColumns();
iconCols_ = declareIconColumns();
@@ -274,6 +319,7 @@ protected:
list_->Bind(wxEVT_LIST_ITEM_SELECTED, &BaseCardListPanel::onSelectionChanged, this);
list_->Bind(wxEVT_LIST_ITEM_DESELECTED, &BaseCardListPanel::onSelectionChanged, this);
list_->Bind(wxEVT_LIST_ITEM_ACTIVATED, &BaseCardListPanel::onListItemActivated, this);
list_->Bind(wxEVT_KEY_DOWN, &BaseCardListPanel::onListKeyDown, this);
}
// Forwarded helpers ------------------------------------------------------
@@ -523,11 +569,12 @@ private:
const bool ascending = (it == nextDirByCol_.end()) ? true : it->second;
nextDirByCol_[*sortCol] = !ascending;
std::optional<std::uint32_t> keepId;
if (auto sel = selected()) keepId = sel->id;
auto ids = selectedIds();
std::optional<std::vector<std::uint32_t>> keepIds;
if (!ids.empty()) keepIds = std::move(ids);
sortBy(*sortCol, ascending);
rebuildRows(keepId);
rebuildRows(keepIds);
if (list_ != nullptr) list_->SetFocus();
}
@@ -590,7 +637,9 @@ private:
// ----- row rendering -----------------------------------------------------
void rebuildRows(std::optional<std::uint32_t> keepId = std::nullopt) {
// nullopt keepIds → no prior selection (startup / empty): defer first-row
// select. Otherwise restore every id that is still visible after filter.
void rebuildRows(std::optional<std::vector<std::uint32_t>> keepIds = std::nullopt) {
// Suppress wxListCtrl's natural DESELECTED (from DeleteAllItems) and
// SELECTED (from the SetItemState below) events while we churn through
// the rebuild. See `ui_wx/AGENTS.md` for the rate-limit rationale.
@@ -605,10 +654,16 @@ private:
}
}
std::unordered_set<std::uint32_t> keepSet;
if (keepIds) {
keepSet.insert(keepIds->begin(), keepIds->end());
}
long row = 0;
long rowToSelect = -1;
long firstRestored = -1;
const int firstText = firstTextColIdx();
const int noteCol = noteColIdx();
std::vector<long> rowsToSelect;
for (std::size_t srcIdx : filteredIndices_) {
const auto& c = cards_[srcIdx];
// Insert via the hidden column-0 spacer. We never set sub-item
@@ -632,16 +687,22 @@ private:
const std::string note = renderTextCell(c, textCols_.size() - 1);
list_->SetItem(idx, noteCol, wxString::FromUTF8(note.c_str()));
if (keepId && c.id == *keepId) rowToSelect = idx;
if (!keepSet.empty() && keepSet.count(c.id) != 0) {
rowsToSelect.push_back(idx);
if (firstRestored < 0) firstRestored = idx;
}
++row;
}
bool deferredInitialSelect = false;
if (!filteredIndices_.empty() && rowToSelect >= 0) {
list_->SetItemState(rowToSelect,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
list_->EnsureVisible(rowToSelect);
} else if (!filteredIndices_.empty() && !keepId.has_value()) {
if (!filteredIndices_.empty() && !rowsToSelect.empty()) {
for (long r : rowsToSelect) {
const long flags = (r == firstRestored)
? (wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED)
: wxLIST_STATE_SELECTED;
list_->SetItemState(r, flags, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
}
list_->EnsureVisible(firstRestored);
} else if (!filteredIndices_.empty() && !keepIds.has_value()) {
// Defer the initial selection to the next event turn so first
// paint stays responsive.
deferredInitialSelect = true;
@@ -705,6 +766,72 @@ private:
});
}
void onListKeyDown(wxKeyEvent& event) {
const int key = event.GetKeyCode();
const bool copyChord =
(event.ControlDown() || event.CmdDown()) && (key == 'C' || key == 'c');
if (!copyChord) {
event.Skip();
return;
}
copySelectedRowsToClipboard();
}
void copySelectedRowsToClipboard() {
const auto cards = selectedCards();
if (cards.empty() || textCols_.empty()) return;
auto formatRow = [&](const TCard& card) {
std::string line;
auto appendCell = [&](std::string_view cell) {
if (!line.empty()) line.push_back('\t');
line.append(cell);
};
// Leading text columns (everything except trailing Note).
for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) {
appendCell(renderTextCell(card, i));
}
// Icon/flag columns — no list text; export as true/false.
for (std::size_t i = 0; i < iconCols_.size(); ++i) {
appendCell(isIconColumnSet(card, i) ? "true" : "false");
}
// Trailing Note.
appendCell(renderTextCell(card, textCols_.size() - 1));
return line;
};
std::string payload = formatRow(cards.front());
for (std::size_t i = 1; i < cards.size(); ++i) {
payload.push_back('\n');
payload.append(formatRow(cards[i]));
}
wxClipboardLocker lock;
if (!lock) return;
if (!wxTheClipboard->SetData(
new wxTextDataObject(wxString::FromUTF8(payload.c_str())))) {
return;
}
if (cards.size() == 1) {
emitUiStatus("Saved entry to clipboard");
} else {
emitUiStatus(wxString::Format("Saved %zu entries to clipboard", cards.size()));
}
}
void emitUiStatus(const wxString& message) {
wxCommandEvent ev(EVT_UI_STATUS, GetId());
ev.SetEventObject(this);
ev.SetString(message);
// Same parent-hop as BaseSelectedCardPanel::emitPreviewStatus so the
// command event can propagate up to MainFrame's status strip.
if (auto* parent = GetParent()) {
parent->GetEventHandler()->ProcessEvent(ev);
} else {
ProcessWindowEvent(ev);
}
}
// ----- members ----------------------------------------------------------
static constexpr int kFlagIconSize = 14;
@@ -49,9 +49,14 @@ private:
std::string setName,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void requestBySetNoAsync(unsigned capturedEpoch,
std::string setName,
std::string setNo,
bool showFailureDialog);
void applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool fillNameOnSuccess,
bool showFailureDialog);
void rebuildVariantRingFromCache();
void syncRingPositionToControls();
@@ -63,6 +63,7 @@ public:
}
private:
void syncEditToolbarVisibility();
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
void ensureSingleCardsMounted(wxWindow* splitterParent);
+5
View File
@@ -19,6 +19,7 @@
#include <string_view>
#include <vector>
class wxBitmapButton;
class wxPanel;
class wxWindow;
@@ -67,6 +68,10 @@ public:
virtual void onEditCard(wxWindow* parentWindow) = 0;
virtual void onDeleteCard(wxWindow* parentWindow) = 0;
// Magic uses MainFrame's shared Edit button; hostsOwnLayout games ignore
// this and manage their own toolbar. Default no-op.
virtual void attachSharedToolbarEdit(wxBitmapButton* edit) { (void)edit; }
// Sets menu action ("Update Magic" / "Update Pokemon"). Returns the
// user-visible status string for the parent's status bar.
virtual std::string onUpdateSets(wxWindow* parentWindow) = 0;
+3
View File
@@ -42,6 +42,7 @@ public:
void onAddCard(wxWindow* parentWindow) override;
void onEditCard(wxWindow* parentWindow) override;
void onDeleteCard(wxWindow* parentWindow) override;
void attachSharedToolbarEdit(wxBitmapButton* edit) override;
std::string onUpdateSets(wxWindow* parentWindow) override;
void setFilter(std::string_view filter) override;
void nudgeSelection(int delta) override;
@@ -51,6 +52,7 @@ public:
private:
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
void syncEditToolbarVisibility();
ConfigService& config_;
CollectionService<MagicCard>& collection_;
@@ -61,6 +63,7 @@ private:
MagicCardListPanel* listPanel_{nullptr};
MagicSelectedCardPanel* selectedPanel_{nullptr};
wxBitmapButton* sharedEditButton_{nullptr};
std::vector<Set> setsCache_;
bool attemptedInitialSetLoad_{false};
};
@@ -62,9 +62,14 @@ private:
std::string setId,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void requestBySetNoAsync(unsigned capturedEpoch,
std::string setId,
std::string setNo,
bool showFailureDialog);
void applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool fillNameOnSuccess,
bool showFailureDialog);
void rebuildVariantRingFromCache();
void syncRingPositionToControls();
+1
View File
@@ -66,6 +66,7 @@ public:
[[nodiscard]] std::string updateSetsMenuLabel() const override { return "Update Pokemon"; }
private:
void syncEditToolbarVisibility();
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog(PokemonRegion region);
void ensureSingleCardsMounted(wxWindow* splitterParent);
+12
View File
@@ -4,6 +4,11 @@
#include <wx/colour.h>
#include <cstddef>
#include <string>
#include <string_view>
class wxBitmapButton;
class wxDialog;
class wxWindow;
class wxString;
@@ -30,4 +35,11 @@ void themeModalDialog(wxDialog* dlg, Theme theme);
int showThemedMessageDialog(wxWindow* parent, const wxString& message, const wxString& caption, long style);
int showThemedConfirmDialog(wxWindow* parent, const wxString& message, const wxString& caption);
// Show/hide the shared or per-game Edit toolbar button and reflow its sizer
// so Add/Delete close the gap when Edit is hidden for multi-select.
void setToolbarEditVisible(wxBitmapButton* edit, bool visible);
// Confirm copy for Delete: one card by name, or "Delete N selected entries?".
wxString deleteCardsConfirmMessage(std::size_t count, std::string_view singleCardName);
} // namespace ccm::ui
@@ -54,7 +54,8 @@ private:
void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch);
void requestByNameAsync(unsigned capturedEpoch, std::string name, std::string setId,
bool showFailureDialog);
void requestByNoAsync(unsigned capturedEpoch, std::string setNo, bool showFailureDialog);
void requestByNoAsync(unsigned capturedEpoch, std::string setId, std::string setNo,
bool showFailureDialog);
void applyDetectedList(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool showFailureDialog, bool applyFirst);
@@ -64,6 +64,7 @@ public:
}
private:
void syncEditToolbarVisibility();
void ensureSetsLoaded();
// Fetches sets + checklist catalog from Yugipedia and persists both.
// Returns false on failure (error dialogs already shown).
@@ -8,6 +8,11 @@
#include <wx/button.h>
#include <wx/stattext.h>
#include <atomic>
#include <memory>
#include <string>
#include <vector>
namespace ccm::ui {
class YuGiOhCardEditDialog final : public BaseCardEditDialog<YuGiOhCard> {
@@ -19,6 +24,7 @@ public:
EditMode mode,
YuGiOhCard initial,
const std::vector<Set>* preloadedSets = nullptr);
~YuGiOhCardEditDialog() override;
protected:
void buildFlagsRow(wxBoxSizer* flagsBox) override;
@@ -31,6 +37,10 @@ protected:
void onSetSelectionApplied() override;
private:
struct VariantFetchState {
std::atomic<bool> alive{true};
};
void onAutoDetectSetNo(wxCommandEvent&);
void onAutoDetectRarity(wxCommandEvent&);
void onNextSetNo(wxCommandEvent&);
@@ -42,6 +52,10 @@ private:
void onSetCodeAutoDetect(wxCommandEvent&);
void syncSetModeHint();
void autoDetectFromApi(bool fillSetNo, bool fillRarity);
void requestBySetNoAsync(unsigned capturedEpoch, std::string setId, std::string setName,
std::string setNo);
void applyReverseDetectedList(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected);
void refreshSetNoFullPreview();
void clearCachedPrintVariants();
bool fetchAndCachePrintVariants();
@@ -59,6 +73,7 @@ private:
EditMode dialogMode_;
unsigned variantFetchEpoch_{0};
CardPreviewService& cardPreview_;
std::shared_ptr<VariantFetchState> variantFetchState_;
wxTextCtrl* setNoCtrl_{nullptr};
wxStaticText* setNoFullPreview_{nullptr};
wxChoice* rarityChoice_{nullptr};
+1
View File
@@ -61,6 +61,7 @@ public:
[[nodiscard]] std::string updateSetsMenuLabel() const override { return "Update Yu-Gi-Oh!"; }
private:
void syncEditToolbarVisibility();
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
void ensureSingleCardsMounted(wxWindow* splitterParent);