Files
Sebastian Dine d3b4762b76 Minor: Additional functions (#25)
* several functions. fixes #1 and #2

* multi selection functionality
2026-08-03 08:35:38 +02:00

874 lines
37 KiB
C++

#pragma once
// BaseCardListPanel<TCard, TSortColumn>
//
// Header-only template that owns ALL the non-game-specific machinery for the
// `wxListCtrl`-backed card table:
//
// - hidden zero-width spacer column (MSW comctl32 image-list gutter
// workaround; see `ui_wx/AGENTS.md` for the rationale)
// - app-owned themed header row (clickable to sort, edge-drag to resize,
// divider double-click to autosize) - native `wxListCtrl` header is
// unreliable in Windows dark mode
// - custom-drawn flag-icon sub-items via `IconListCtrl` so row icons sit
// pixel-perfect centered under the themed-header icons regardless of
// column width (native `LVS_REPORT` sub-item images left-anchor with an
// inset and would never align with our centered header icons)
// - rebuild guard so DESELECTED/SELECTED storms during rebuild collapse
// into a single bubbled `EVT_CARD_SELECTED` event
// - case-insensitive substring filter via `setFilter(...)` and per-column
// toggle-direction sort via the header click
//
// Game-specific behavior is exposed as virtual hooks the derived class fills
// in (template method pattern):
//
// declareTextColumns() -> spec list (label, width, format) for the leading
// "value-key" columns and the trailing Note column
// declareIconColumns() -> spec list (svg, width, sortColumn) for icon-only
// flag columns (foil/signed/altered, holo, ...)
// renderTextCell(card, idx) -> cell string for text column `idx`
// isIconColumnSet(card, idx) -> whether the n-th icon column shows for this card
// sortColumnForListIdx(col) -> map physical wxListCtrl column to sort key
// sortBy(col, asc) -> in-place stable sort of `cards_`
// matchesFilter(card, f) -> case-insensitive row matcher
//
// New games extend this template — see `MagicCardListPanel` and
// `PokemonCardListPanel` for the canonical patterns.
#include "ccm/ui/IconListCtrl.hpp"
#include "ccm/ui/SvgIcons.hpp"
#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>
#include <wx/panel.h>
#include <wx/sizer.h>
#include <wx/statbmp.h>
#include <wx/stattext.h>
#include <wx/utils.h>
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
namespace ccm::ui {
// Single shared selection-changed event. The base panel raises this on the
// parent every time the active card changes (after a rebuild settles, after a
// user click, etc.). Defined once in `BaseEvents.cpp` so the wxEvent table is
// not duplicated per template instantiation.
wxDECLARE_EVENT(EVT_CARD_SELECTED, wxCommandEvent);
// Raised on `wxEVT_LIST_ITEM_ACTIVATED` (double-click / Enter on a row).
// `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:
using card_type = TCard;
using sort_column_type = TSortColumn;
// Replace the displayed rows. When preferSelectId is set, selects that
// 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::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
// shown in the header should match the order actually rendered, and
// wxListCtrl keeps the indicator across DeleteAllItems().
nextDirByCol_.clear();
list_->RemoveSortIndicator();
rebuildRows(keepIds);
if (!autoSizedOnce_ && !cards_.empty()) {
autoSizeAllColumns();
autoSizedOnce_ = true;
}
}
// Update the filter string and rebuild the visible rows in place. The
// 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);
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) {
list_->SetBackgroundColour(palette.inputBg);
list_->SetForegroundColour(palette.inputText);
SetBackgroundColour(palette.panelBg);
SetForegroundColour(palette.text);
rebuildIconBitmaps(palette.inputText, wxColour(255, 255, 255));
refreshHeaderTheme(palette);
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 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);
if (row < 0) row = 0;
list_->SetItemState(row,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
list_->EnsureVisible(row);
list_->SetFocus();
}
// Move the selection by `delta` rows (+1 / -1). Used when Up/Down are
// 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);
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;
suppressListFocus_ = true;
// 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);
list_->EnsureVisible(next);
suppressListFocus_ = false;
}
protected:
// Column descriptor types -------------------------------------------------
struct TextColumnSpec {
std::string label;
int width;
wxListColumnFormat format; // wxLIST_FORMAT_LEFT / RIGHT / CENTER
std::optional<TSortColumn> sortColumn; // none = not sortable
};
struct IconColumnSpec {
const char* svg;
int width;
std::optional<TSortColumn> sortColumn;
};
// Subclass hooks ----------------------------------------------------------
// Subclass declares its leading text columns (Name, Set, ...). Order
// matches the on-screen left-to-right ordering. The trailing "Note" column
// is also returned here as the last entry — it is added AFTER the icon
// columns by the base.
[[nodiscard]] virtual std::vector<TextColumnSpec> declareTextColumns() const = 0;
// Subclass declares the icon flag columns (Foil/Signed/Altered, etc.).
// These render between the leading text columns and the trailing Note.
[[nodiscard]] virtual std::vector<IconColumnSpec> declareIconColumns() const = 0;
[[nodiscard]] virtual std::string renderTextCell(const TCard& card, std::size_t idx) const = 0;
[[nodiscard]] virtual bool isIconColumnSet(const TCard& card, std::size_t idx) const = 0;
virtual void sortBy(TSortColumn column, bool ascending) = 0;
[[nodiscard]] virtual bool matchesFilter(const TCard& card, std::string_view filter) const = 0;
// Construction ------------------------------------------------------------
explicit BaseCardListPanel(wxWindow* parent) : wxPanel(parent, wxID_ANY) {}
// 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_NO_HEADER);
textCols_ = declareTextColumns();
iconCols_ = declareIconColumns();
// Note must be the *last* text column. We render it after the icons.
// Layout: [hidden spacer] [textCols_-1 leading text cols] [icon cols] [last text col].
if (textCols_.empty()) {
// No text columns at all is unsupported; the trailing note column
// is required by the panel layout.
textCols_.push_back({"Note", 220, wxLIST_FORMAT_LEFT, std::nullopt});
}
buildHeaderRow();
// Column 0 is a hidden spacer kept for historical reasons (it used
// to swallow MSW's mandatory item-icon gutter when we had an image
// list). It is harmless now that row icons go through NM_CUSTOMDRAW
// and is preserved so existing column-index math stays correct.
list_->AppendColumn("", wxLIST_FORMAT_LEFT, 0);
// Leading text columns (everything except the last).
for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) {
list_->AppendColumn(textCols_[i].label, textCols_[i].format, textCols_[i].width);
}
// Icon columns. Format is irrelevant here — we paint the icon
// ourselves, exactly centered, in `IconListCtrl::MSWOnNotify`.
for (const auto& ic : iconCols_) {
list_->AppendColumn("", wxLIST_FORMAT_CENTER, ic.width);
}
rebuildIconBitmaps(wxColour(20, 20, 20), wxColour(255, 255, 255));
// Trailing Note column.
const auto& last = textCols_.back();
list_->AppendColumn(last.label, last.format, last.width);
// Wire NM_CUSTOMDRAW callbacks so row icons render centered in their
// sub-item rect. The predicate maps a (row, iconIdx) back through the
// filtered card vector so we ask the same `isIconColumnSet(...)` hook
// the rest of the panel uses. The bitmap cache was already pushed
// into `list_` by `rebuildIconBitmaps(...)` above.
list_->setIconColumns(firstIconColIdx(), iconColCount());
list_->setIconPredicate([this](long row, int iconIdx) {
const TCard* c = cardForRow(row);
if (c == nullptr) return false;
if (iconIdx < 0 || static_cast<std::size_t>(iconIdx) >= iconCols_.size()) {
return false;
}
return isIconColumnSet(*c, static_cast<std::size_t>(iconIdx));
});
auto* sizer = new wxBoxSizer(wxVERTICAL);
sizer->Add(headerRow_, 0, wxEXPAND);
sizer->Add(list_, 1, wxEXPAND);
SetSizer(sizer);
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 ------------------------------------------------------
// wxListCtrl column indices for derived helpers.
[[nodiscard]] int firstTextColIdx() const noexcept { return 1; }
[[nodiscard]] int firstIconColIdx() const noexcept {
return 1 + static_cast<int>(textCols_.size()) - 1;
}
[[nodiscard]] int noteColIdx() const noexcept {
return firstIconColIdx() + static_cast<int>(iconCols_.size());
}
[[nodiscard]] int textColCount() const noexcept {
return static_cast<int>(textCols_.size());
}
[[nodiscard]] int iconColCount() const noexcept {
return static_cast<int>(iconCols_.size());
}
[[nodiscard]] wxListCtrl* listCtrl() const noexcept { return list_; }
// Mutable access to the underlying vector for the typed `sortBy` hook
// (the sort runs in-place on the same vector the base owns, so we can't
// hand the subclass a copy).
[[nodiscard]] std::vector<TCard>& mutableCards() noexcept { return cards_; }
private:
// ----- header row construction -------------------------------------------
void buildHeaderRow() {
headerRow_ = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE);
auto* s = new wxBoxSizer(wxHORIZONTAL);
headerCells_.clear();
headerCellToCol_.clear();
headerIcons_.clear();
auto bindHeaderEvents = [this](wxWindow* hit, int col) {
hit->Bind(wxEVT_LEFT_DOWN, [this, col](wxMouseEvent& ev) { onHeaderMouseDown(col, ev); });
hit->Bind(wxEVT_MOTION, [this, col](wxMouseEvent& ev) { onHeaderMouseMove(col, ev); });
hit->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent& ev) { onHeaderMouseUp(ev); });
hit->Bind(wxEVT_LEFT_DCLICK,
[this, col](wxMouseEvent& ev) { onHeaderDoubleClick(col, ev); });
};
auto addText = [&](const wxString& label, int width, int col) {
auto* p = new wxPanel(headerRow_, wxID_ANY, wxDefaultPosition, wxSize(width, -1), wxBORDER_NONE);
auto* ps = new wxBoxSizer(wxHORIZONTAL);
auto* t = new wxStaticText(p, wxID_ANY, label);
ps->Add(t, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, 4);
p->SetSizer(ps);
p->SetMinSize(wxSize(width, -1));
bindHeaderEvents(p, col);
bindHeaderEvents(t, col);
s->Add(p, 0, wxEXPAND);
headerCells_.push_back(p);
headerCellToCol_[p] = col;
};
auto addIcon = [&](const char* svg, int width, int col) {
auto* p = new wxPanel(headerRow_, wxID_ANY, wxDefaultPosition, wxSize(width, -1), wxBORDER_NONE);
auto* ps = new wxBoxSizer(wxHORIZONTAL);
auto bmp = svgIconBitmap(svg, kFlagIconSize, "#E6E6E6");
auto* b = new wxStaticBitmap(p, wxID_ANY, bmp);
ps->AddStretchSpacer(1);
ps->Add(b, 0, wxALIGN_CENTER_VERTICAL);
ps->AddStretchSpacer(1);
p->SetSizer(ps);
p->SetMinSize(wxSize(width, -1));
bindHeaderEvents(p, col);
bindHeaderEvents(b, col);
s->Add(p, 0, wxEXPAND);
headerCells_.push_back(p);
headerCellToCol_[p] = col;
headerIcons_.push_back({b, svg});
};
const int firstText = firstTextColIdx();
// Leading text columns.
for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) {
addText(textCols_[i].label, textCols_[i].width, firstText + static_cast<int>(i));
}
const int firstIcon = firstIconColIdx();
for (std::size_t i = 0; i < iconCols_.size(); ++i) {
addIcon(iconCols_[i].svg, iconCols_[i].width, firstIcon + static_cast<int>(i));
}
const int noteCol = noteColIdx();
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 ---------------------------------
[[nodiscard]] bool isResizeGripHit(int col, int x) const {
const int firstText = firstTextColIdx();
if (col < firstText || col > noteColIdx()) return false;
const std::size_t idx = static_cast<std::size_t>(col - firstText);
if (idx >= headerCells_.size() || headerCells_[idx] == nullptr) return false;
const int w = headerCells_[idx]->GetSize().GetWidth();
return x >= (w - kResizeGripPx);
}
void setColumnWidth(int col, int width) {
// Icon columns get a tighter min so they don't grow when dragged.
const int firstIcon = firstIconColIdx();
const int lastIcon = firstIcon + iconColCount() - 1;
const int minWidth = (col >= firstIcon && col <= lastIcon) ? 24 : 40;
const int nextWidth = std::max(minWidth, width);
list_->SetColumnWidth(col, nextWidth);
const std::size_t idx = static_cast<std::size_t>(col - firstTextColIdx());
if (idx < headerCells_.size() && headerCells_[idx] != nullptr) {
headerCells_[idx]->SetMinSize(wxSize(nextWidth, -1));
}
// Row icons are drawn from the live sub-item rect via NM_CUSTOMDRAW,
// so column resizing automatically re-centers them on the next paint
// — no image-list rebuild needed.
headerRow_->Layout();
}
void autoSizeColumn(int col) {
list_->SetColumnWidth(col, wxLIST_AUTOSIZE);
const int contentWidth = list_->GetColumnWidth(col);
list_->SetColumnWidth(col, wxLIST_AUTOSIZE_USEHEADER);
const int headerWidth = list_->GetColumnWidth(col);
setColumnWidth(col, std::max(contentWidth, headerWidth));
}
void autoSizeAllColumns() {
for (int col = firstTextColIdx(); col <= noteColIdx(); ++col) {
autoSizeColumn(col);
}
}
void onHeaderMouseDown(int col, wxMouseEvent& ev) {
wxWindow* src = dynamic_cast<wxWindow*>(ev.GetEventObject());
wxWindow* cell = src;
while (cell != nullptr && cell->GetParent() != headerRow_) {
cell = cell->GetParent();
}
if (cell == nullptr) return;
const wxPoint posInCell = cell->ScreenToClient(src->ClientToScreen(ev.GetPosition()));
if (!isResizeGripHit(col, posInCell.x)) return;
resizingCol_ = true;
activeResizeCol_ = col;
resizeStartScreenX_ = wxGetMousePosition().x;
resizeStartWidth_ = list_->GetColumnWidth(col);
cell->CaptureMouse();
}
void onHeaderMouseMove(int col, wxMouseEvent& ev) {
wxWindow* src = dynamic_cast<wxWindow*>(ev.GetEventObject());
wxWindow* cell = src;
while (cell != nullptr && cell->GetParent() != headerRow_) {
cell = cell->GetParent();
}
if (cell == nullptr) return;
if (resizingCol_ && activeResizeCol_ == col && cell->HasCapture()) {
const int delta = wxGetMousePosition().x - resizeStartScreenX_;
setColumnWidth(col, resizeStartWidth_ + delta);
return;
}
const wxPoint posInCell = cell->ScreenToClient(src->ClientToScreen(ev.GetPosition()));
cell->SetCursor(isResizeGripHit(col, posInCell.x)
? wxCursor(wxCURSOR_SIZEWE)
: wxCursor(wxCURSOR_ARROW));
}
void onHeaderMouseUp(wxMouseEvent& ev) {
const bool wasResizing = resizingCol_;
wxWindow* src = dynamic_cast<wxWindow*>(ev.GetEventObject());
wxWindow* cell = src;
while (cell != nullptr && cell->GetParent() != headerRow_) {
cell = cell->GetParent();
}
if (cell != nullptr && cell->HasCapture()) {
cell->ReleaseMouse();
}
resizingCol_ = false;
if (suppressNextHeaderClick_) {
suppressNextHeaderClick_ = false;
activeResizeCol_ = -1;
return;
}
if (!wasResizing && cell != nullptr) {
auto it = headerCellToCol_.find(cell);
if (it != headerCellToCol_.end()) {
onHeaderClick(it->second);
}
}
activeResizeCol_ = -1;
}
void onHeaderDoubleClick(int col, wxMouseEvent& ev) {
wxWindow* src = dynamic_cast<wxWindow*>(ev.GetEventObject());
wxWindow* cell = src;
while (cell != nullptr && cell->GetParent() != headerRow_) {
cell = cell->GetParent();
}
if (cell == nullptr) return;
const wxPoint posInCell = cell->ScreenToClient(src->ClientToScreen(ev.GetPosition()));
if (isResizeGripHit(col, posInCell.x)) {
suppressNextHeaderClick_ = true;
autoSizeColumn(col);
}
}
// Map a physical wxListCtrl column to a sort column. Looks at the
// declared TextColumnSpec/IconColumnSpec lists to find the optional
// `sortColumn` for each column. Returns nullopt for non-sortable columns
// (the spacer column 0 or any text/icon column without a sort key).
[[nodiscard]] std::optional<TSortColumn> sortColumnForListIdx(int listColIdx) const {
if (listColIdx <= 0) return std::nullopt;
const int firstIcon = firstIconColIdx();
const int noteCol = noteColIdx();
if (listColIdx < firstIcon) {
const std::size_t i = static_cast<std::size_t>(listColIdx - firstTextColIdx());
if (i < textCols_.size() - 1) return textCols_[i].sortColumn;
} else if (listColIdx < noteCol) {
const std::size_t i = static_cast<std::size_t>(listColIdx - firstIcon);
if (i < iconCols_.size()) return iconCols_[i].sortColumn;
} else if (listColIdx == noteCol) {
return textCols_.back().sortColumn;
}
return std::nullopt;
}
void onHeaderClick(int col) {
if (resizingCol_) return;
const auto sortCol = sortColumnForListIdx(col);
if (!sortCol) return;
// Per-column toggle, faithful to TableTemplate.tsx::sortByField.
auto it = nextDirByCol_.find(*sortCol);
const bool ascending = (it == nextDirByCol_.end()) ? true : it->second;
nextDirByCol_[*sortCol] = !ascending;
auto ids = selectedIds();
std::optional<std::vector<std::uint32_t>> keepIds;
if (!ids.empty()) keepIds = std::move(ids);
sortBy(*sortCol, ascending);
rebuildRows(keepIds);
if (list_ != nullptr) list_->SetFocus();
}
// ----- cached icon bitmaps for NM_CUSTOMDRAW -----------------------------
// Pre-renders the per-icon-column bitmaps used by the custom-draw path in
// `IconListCtrl`. Two color variants per column: the `normal` color for
// unselected rows (paired with the panel's themed text color) and the
// `selected` color drawn on the highlighted row. After rebuilding, the
// bitmaps are pushed into `IconListCtrl` which converts them into a
// single `HIMAGELIST` for `ImageList_Draw` from `NM_CUSTOMDRAW`. See
// `ui_wx/AGENTS.md` convention 11 for why earlier `wxGraphicsContext::
// DrawBitmap` and raw `AlphaBlend` paths were abandoned.
void rebuildIconBitmaps(const wxColour& normal, const wxColour& selected) {
iconBitmapsNormal_.clear();
iconBitmapsSelected_.clear();
iconBitmapsNormal_.reserve(iconCols_.size());
iconBitmapsSelected_.reserve(iconCols_.size());
const std::string normalHex = normal.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
const std::string selectedHex = selected.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
for (const auto& ic : iconCols_) {
iconBitmapsNormal_.push_back(
svgIconBitmap(ic.svg, kFlagIconSize, normalHex.c_str()));
iconBitmapsSelected_.push_back(
svgIconBitmap(ic.svg, kFlagIconSize, selectedHex.c_str()));
}
if (list_ != nullptr) {
list_->setIconBitmaps(iconBitmapsNormal_, iconBitmapsSelected_);
}
}
void refreshHeaderTheme(const ThemePalette& palette) {
headerRow_->SetBackgroundColour(palette.inputBg);
headerRow_->SetForegroundColour(palette.inputText);
headerRow_->SetOwnBackgroundColour(palette.inputBg);
headerRow_->SetOwnForegroundColour(palette.inputText);
for (wxWindow* cell : headerCells_) {
if (cell == nullptr) continue;
cell->SetBackgroundColour(palette.inputBg);
cell->SetForegroundColour(palette.inputText);
cell->SetOwnBackgroundColour(palette.inputBg);
cell->SetOwnForegroundColour(palette.inputText);
const wxWindowList& children = cell->GetChildren();
for (wxWindowList::compatibility_iterator it = children.GetFirst(); it; it = it->GetNext()) {
wxWindow* child = it->GetData();
if (child == nullptr) continue;
child->SetBackgroundColour(palette.inputBg);
child->SetForegroundColour(palette.inputText);
child->SetOwnBackgroundColour(palette.inputBg);
child->SetOwnForegroundColour(palette.inputText);
}
}
const std::string iconHex = palette.inputText.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
for (auto& it : headerIcons_) {
if (it.first == nullptr || it.second == nullptr) continue;
it.first->SetBitmap(svgIconBitmap(it.second, kFlagIconSize, iconHex.c_str()));
}
headerRow_->Refresh();
}
// ----- row rendering -----------------------------------------------------
// 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.
inRebuild_ = true;
list_->DeleteAllItems();
filteredIndices_.clear();
filteredIndices_.reserve(cards_.size());
for (std::size_t i = 0; i < cards_.size(); ++i) {
if (matchesFilter(cards_[i], filter_)) {
filteredIndices_.push_back(i);
}
}
std::unordered_set<std::uint32_t> keepSet;
if (keepIds) {
keepSet.insert(keepIds->begin(), keepIds->end());
}
long row = 0;
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
// images: row icons are drawn through `IconListCtrl` custom-draw
// straight onto the device context, exactly centered in the cell.
wxListItem spacerItem;
spacerItem.SetId(row);
spacerItem.SetText("");
spacerItem.SetImage(-1);
spacerItem.SetMask(wxLIST_MASK_TEXT | wxLIST_MASK_IMAGE);
const long idx = list_->InsertItem(spacerItem);
// Leading text columns.
for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) {
const std::string cell = renderTextCell(c, i);
list_->SetItem(idx, firstText + static_cast<int>(i),
wxString::FromUTF8(cell.c_str()));
}
// Trailing Note text column. Icon columns intentionally have no
// text and no image — the custom-draw paints them.
const std::string note = renderTextCell(c, textCols_.size() - 1);
list_->SetItem(idx, noteCol, wxString::FromUTF8(note.c_str()));
if (!keepSet.empty() && keepSet.count(c.id) != 0) {
rowsToSelect.push_back(idx);
if (firstRestored < 0) firstRestored = idx;
}
++row;
}
bool deferredInitialSelect = false;
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;
CallAfter([this]() {
if (list_ == nullptr || list_->GetItemCount() <= 0) return;
const long sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
if (sel >= 0) return;
list_->SetItemState(0,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
list_->EnsureVisible(0);
});
}
inRebuild_ = false;
if (!deferredInitialSelect) {
notifySelectionChanged();
}
}
void notifySelectionChanged() {
// Fires the event on the panel itself. The owning IGameView binds
// directly to its typed list panel so the typed selection wiring stays
// local (MainFrame only sees IGameView, never MagicCard / PokemonCard).
wxCommandEvent ev(EVT_CARD_SELECTED, GetId());
ev.SetEventObject(this);
ProcessWindowEvent(ev);
}
[[nodiscard]] const TCard* cardForRow(long row) const noexcept {
if (row < 0) return nullptr;
const auto r = static_cast<std::size_t>(row);
if (r >= filteredIndices_.size()) return nullptr;
const std::size_t srcIdx = filteredIndices_[r];
if (srcIdx >= cards_.size()) return nullptr;
return &cards_[srcIdx];
}
void onSelectionChanged(wxListEvent& event) {
// wxListCtrl invalidates the row when its selection state changes,
// which re-fires NM_CUSTOMDRAW with the new `CDIS_SELECTED` flag.
// The icon bitmap provider returns the selected-color variant, so
// 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;
// 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);
});
}
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;
static constexpr int kResizeGripPx = 5;
wxPanel* headerRow_{nullptr};
std::vector<wxWindow*> headerCells_;
std::vector<std::pair<wxStaticBitmap*, const char*>> headerIcons_;
std::unordered_map<wxWindow*, int> headerCellToCol_;
IconListCtrl* list_{nullptr};
std::vector<TextColumnSpec> textCols_;
std::vector<IconColumnSpec> iconCols_;
bool resizingCol_{false};
bool suppressNextHeaderClick_{false};
bool autoSizedOnce_{false};
int activeResizeCol_{-1};
int resizeStartScreenX_{0};
int resizeStartWidth_{0};
std::vector<TCard> cards_;
std::vector<std::size_t> filteredIndices_;
std::string filter_;
// 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_;
// Per-icon-column cached bitmaps consumed by `IconListCtrl`'s NM_CUSTOMDRAW
// path. Index aligns with `iconCols_`.
std::vector<wxBitmap> iconBitmapsNormal_;
std::vector<wxBitmap> iconBitmapsSelected_;
};
} // namespace ccm::ui