#pragma once // BaseCardListPanel // // 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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 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 cards, std::optional preferSelectId = std::nullopt) { std::optional> keepIds; if (preferSelectId) { keepIds = std::vector{*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> 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> keepIds; if (!ids.empty()) keepIds = std::move(ids); rebuildRows(keepIds); Refresh(); } [[nodiscard]] const std::vector& 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 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 selectedCards() const { std::vector 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 selectedIds() const { std::vector 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 sortColumn; // none = not sortable }; struct IconColumnSpec { const char* svg; int width; std::optional 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 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 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(iconIdx) >= iconCols_.size()) { return false; } return isIconColumnSet(*c, static_cast(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(textCols_.size()) - 1; } [[nodiscard]] int noteColIdx() const noexcept { return firstIconColIdx() + static_cast(iconCols_.size()); } [[nodiscard]] int textColCount() const noexcept { return static_cast(textCols_.size()); } [[nodiscard]] int iconColCount() const noexcept { return static_cast(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& 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(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(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(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(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(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(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(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(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 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(listColIdx - firstTextColIdx()); if (i < textCols_.size() - 1) return textCols_[i].sortColumn; } else if (listColIdx < noteCol) { const std::size_t i = static_cast(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> 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> 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 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 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(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(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 headerCells_; std::vector> headerIcons_; std::unordered_map headerCellToCol_; IconListCtrl* list_{nullptr}; std::vector textCols_; std::vector iconCols_; bool resizingCol_{false}; bool suppressNextHeaderClick_{false}; bool autoSizedOnce_{false}; int activeResizeCol_{-1}; int resizeStartScreenX_{0}; int resizeStartWidth_{0}; std::vector cards_; std::vector 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 nextDirByCol_; // Per-icon-column cached bitmaps consumed by `IconListCtrl`'s NM_CUSTOMDRAW // path. Index aligns with `iconCols_`. std::vector iconBitmapsNormal_; std::vector iconBitmapsSelected_; }; } // namespace ccm::ui