mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 17:01:02 +00:00
d3b4762b76
* several functions. fixes #1 and #2 * multi selection functionality
874 lines
37 KiB
C++
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
|