[VisualDatabase] Draw the card grid with a model/view (#7701)

* [VisualDatabase] Draw the card grid with a model/view instead of a widget per card

The visual card database built one CardInfoPictureWithTextOverlayWidget per
card, 100 per page, and tore them down on every search, resize and scroll
page. It also looked each card up again by name from the model row, kept a
shadow QList<ExactCard>, and carried paging state plus a 5 s timer that only
existed to defer the first page.

Replace that with a real view on a flattened model:

- CardDatabaseDisplayModel gains CardInfoRole and cardInfoAt(), so a view can
  get the CardInfoPtr behind a proxy row without a name round trip.
- New CardDatabaseVisualDisplayModel (libcockatrice_models) flattens the
  filtered and sorted proxy into one entry per displayed card picture: the
  preferred printing normally, every printing of the selected sets while set
  filters are active. Proxy changes rebuild it on a 30 ms coalescing timer.
- New CardDatabaseVisualView (QListView IconMode) paints only visible rows
  through the new CardPictureDelegate, keeps the old interaction contract
  (left/right/middle press -> cardClicked, hover -> cardHovered, Ctrl+wheel
  resize, no selection, no focus stealing, scroll to top on a new result) and
  repaints when CardPictureLoader reports an async image.
- CardSizeWidget::enableCtrlScrollResize() also watches scroll-area viewports,
  which is what makes Ctrl+wheel work over a QListView.
- VisualDatabaseDisplayWidget drops the paging, the shadow list, the dead
  members and the 5 s timer.

Behaviour-preserving: no gameplay change, no new user-facing strings.

* [VisualDatabase] Fix the card grid wheel step

QListView hands the delegate's item height to the scroll bar as its single step under ScrollPerPixel, so QAbstractSlider moved wheelScrollLines() whole card rows per notch. Handle the wheel in the view instead: one line is an eighth of the grid cell, so a notch moves about a third of a card row at any card size and still follows the OS line count.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-11-02 08:35:58 +01:00 • committed by GitHub
parent 94e6281d87
commit a1a8a59f4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1019 additions and 277 deletions

View file

@ -171,6 +171,7 @@ set(cockatrice_SOURCES
src/interface/widgets/cards/card_info_picture_widget.cpp src/interface/widgets/cards/card_info_picture_widget.cpp
src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp
src/interface/widgets/cards/card_info_text_widget.cpp src/interface/widgets/cards/card_info_text_widget.cpp
src/interface/widgets/cards/card_picture_delegate.cpp
src/interface/widgets/cards/card_size_widget.cpp src/interface/widgets/cards/card_size_widget.cpp
src/interface/widgets/cards/deck_card_zone_display_widget.cpp src/interface/widgets/cards/deck_card_zone_display_widget.cpp
src/interface/widgets/cards/deck_preview_card_picture_widget.cpp src/interface/widgets/cards/deck_preview_card_picture_widget.cpp
@ -290,6 +291,7 @@ set(cockatrice_SOURCES
src/interface/widgets/utility/sequence_edit.cpp src/interface/widgets/utility/sequence_edit.cpp
src/interface/widgets/utility/visibility_change_listener.cpp src/interface/widgets/utility/visibility_change_listener.cpp
src/interface/widgets/utility/visibility_change_listener.h src/interface/widgets/utility/visibility_change_listener.h
src/interface/widgets/visual_database_display/card_database_visual_view.cpp
src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp
src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp
src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp

View file

@ -11,6 +11,27 @@
class QObject; class QObject;
namespace
{
/**
* @brief Visits every filter item below @p node, depth first.
*/
template <typename Callback> void forEachFilter(const FilterTreeNode *node, const Callback &callback)
{
if (node == nullptr) {
return;
}
if (const auto *filterItem = dynamic_cast<const FilterItem *>(node)) {
callback(filterItem);
}
for (int i = 0; i < node->childCount(); ++i) {
forEachFilter(node->nodeAt(i), callback);
}
}
} // namespace
FilterTreeModel::FilterTreeModel(QObject *parent) : QAbstractItemModel(parent) FilterTreeModel::FilterTreeModel(QObject *parent) : QAbstractItemModel(parent)
{ {
fTree = new FilterTree; fTree = new FilterTree;
@ -110,46 +131,31 @@ void FilterTreeModel::clearFiltersOfType(CardFilter::Attr filterType)
QList<const CardFilter *> FilterTreeModel::getFiltersOfType(CardFilter::Attr filterType) const QList<const CardFilter *> FilterTreeModel::getFiltersOfType(CardFilter::Attr filterType) const
{ {
QList<const CardFilter *> filters; QList<const CardFilter *> filters;
if (!fTree) { forEachFilter(fTree, [&](const FilterItem *filterItem) {
return filters;
}
std::function<void(const FilterTreeNode *)> traverse;
traverse = [&](const FilterTreeNode *node) {
if (const auto *filterItem = dynamic_cast<const FilterItem *>(node)) {
if (filterItem->attr() == filterType) { if (filterItem->attr() == filterType) {
QString text = filterItem->text(); filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr()));
filters.append(new CardFilter(text, filterItem->type(), filterItem->attr()));
} }
} });
for (int i = 0; i < node->childCount(); ++i) {
traverse(node->nodeAt(i));
}
};
traverse(fTree);
return filters; return filters;
} }
QStringList FilterTreeModel::getFilterTermsOfType(CardFilter::Attr filterType) const
{
QStringList terms;
forEachFilter(fTree, [&](const FilterItem *filterItem) {
if (filterItem->attr() == filterType) {
terms.append(filterItem->text());
}
});
return terms;
}
QList<const CardFilter *> FilterTreeModel::allFilters() const QList<const CardFilter *> FilterTreeModel::allFilters() const
{ {
QList<const CardFilter *> filters; QList<const CardFilter *> filters;
if (!fTree) { forEachFilter(fTree, [&](const FilterItem *filterItem) {
return filters; filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr()));
} });
std::function<void(const FilterTreeNode *)> traverse;
traverse = [&](const FilterTreeNode *node) {
if (const auto *filterItem = dynamic_cast<const FilterItem *>(node)) {
QString text = filterItem->text();
filters.append(new CardFilter(text, filterItem->type(), filterItem->attr()));
}
for (int i = 0; i < node->childCount(); ++i) {
traverse(node->nodeAt(i));
}
};
traverse(fTree);
return filters; return filters;
} }

View file

@ -28,7 +28,14 @@ public slots:
void addFilter(const CardFilter *f); void addFilter(const CardFilter *f);
void removeFilter(const CardFilter *f); void removeFilter(const CardFilter *f);
void clearFiltersOfType(CardFilter::Attr filterType); void clearFiltersOfType(CardFilter::Attr filterType);
/**
* @brief Copies every active filter of one attribute into new CardFilter objects.
*
* The caller owns the returned filters. Prefer getFilterTermsOfType() when only
* the terms are needed.
*/
[[nodiscard]] QList<const CardFilter *> getFiltersOfType(CardFilter::Attr filterType) const; [[nodiscard]] QList<const CardFilter *> getFiltersOfType(CardFilter::Attr filterType) const;
/// @brief Copies every active filter into new CardFilter objects. The caller owns them.
[[nodiscard]] QList<const CardFilter *> allFilters() const; [[nodiscard]] QList<const CardFilter *> allFilters() const;
private slots: private slots:
@ -48,6 +55,13 @@ public:
{ {
return fTree; return fTree;
} }
/**
* @brief The texts of every active filter of one attribute, without allocating filters.
*
* Prefer this over getFiltersOfType() when only the filter terms are needed.
*/
[[nodiscard]] QStringList getFilterTermsOfType(CardFilter::Attr filterType) const;
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
[[nodiscard]] int columnCount(const QModelIndex & /*parent*/ = QModelIndex()) const override; [[nodiscard]] int columnCount(const QModelIndex & /*parent*/ = QModelIndex()) const override;
[[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override;

View file

@ -238,6 +238,7 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image)
}); });
card.emitPixmapUpdated(); card.emitPixmapUpdated();
emit getInstance().pixmapLoaded(card);
} }
void CardPictureLoader::deleteAllLocalOverrides(const ExactCard &card) void CardPictureLoader::deleteAllLocalOverrides(const ExactCard &card)

View file

@ -146,6 +146,14 @@ signals:
/** @brief Emitted after the worker has finished clearing the network and redirect caches. */ /** @brief Emitted after the worker has finished clearing the network and redirect caches. */
void networkCacheCleared(); void networkCacheCleared();
/**
* @brief Emitted once an image has been loaded and put into the pixmap cache.
*
* Views that paint cards from the loader without owning a widget per card can
* listen to this to repaint the rows whose picture just became available.
*/
void pixmapLoaded(const ExactCard &card);
private slots: private slots:
/** /**
* @brief Triggered when the user changes the picture download settings. * @brief Triggered when the user changes the picture download settings.

View file

@ -43,11 +43,6 @@
#include <qlogging.h> #include <qlogging.h>
#include <qnamespace.h> #include <qnamespace.h>
static constexpr qreal MTG_CARD_ASPECT_RATIO = 1.396;
// static constexpr qreal YUGIOH_CARD_ASPECT_RATIO = 1.457;
static constexpr qreal ASPECT_RATIO = MTG_CARD_ASPECT_RATIO;
static constexpr int BASE_WIDTH = 200;
static constexpr int BASE_HEIGHT = 200; static constexpr int BASE_HEIGHT = 200;
static constexpr int ENLARGED_PIXMAP_OFFSET = 10; static constexpr int ENLARGED_PIXMAP_OFFSET = 10;
@ -162,12 +157,11 @@ void CardInfoPictureWidget::resizeEvent(QResizeEvent *event)
*/ */
void CardInfoPictureWidget::setScaleFactor(const int scale) void CardInfoPictureWidget::setScaleFactor(const int scale)
{ {
const int newWidth = BASE_WIDTH * scale / 100; const QSize newSize = sizeForScaleFactor(scale);
const int newHeight = static_cast<int>(newWidth * ASPECT_RATIO);
scaleFactor = scale; scaleFactor = scale;
setFixedSize(newWidth, newHeight); setFixedSize(newSize);
updatePixmap(); updatePixmap();
emit cardScaleFactorChanged(scale); emit cardScaleFactorChanged(scale);
@ -266,8 +260,13 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event)
*/ */
QSize CardInfoPictureWidget::sizeHint() const QSize CardInfoPictureWidget::sizeHint() const
{ {
return {static_cast<int>(BASE_WIDTH * scaleFactor / 100.0), return sizeForScaleFactor(scaleFactor);
static_cast<int>(BASE_WIDTH * scaleFactor / 100.0 * ASPECT_RATIO)}; }
QSize CardInfoPictureWidget::sizeForScaleFactor(int scaleFactor)
{
const int width = BASE_WIDTH * scaleFactor / 100;
return {width, static_cast<int>(width * ASPECT_RATIO)};
} }
/** /**

View file

@ -28,6 +28,11 @@ class CardInfoPictureWidget : public QWidget
Q_OBJECT Q_OBJECT
public: public:
/// Width of a card picture at 100 % scale, in pixels.
static constexpr int BASE_WIDTH = 200;
/// Height / width of a card picture.
static constexpr qreal ASPECT_RATIO = 1.396;
explicit CardInfoPictureWidget(QWidget *parent = nullptr, explicit CardInfoPictureWidget(QWidget *parent = nullptr,
bool hoverToZoomEnabled = false, bool hoverToZoomEnabled = false,
bool raiseOnEnter = false); bool raiseOnEnter = false);
@ -37,6 +42,12 @@ public:
} }
[[nodiscard]] QSize sizeHint() const override; [[nodiscard]] QSize sizeHint() const override;
/**
* @brief The size a card picture has at @p scaleFactor percent.
* @param scaleFactor Percentage of BASE_WIDTH, as set by the card size slider.
*/
[[nodiscard]] static QSize sizeForScaleFactor(int scaleFactor);
public slots: public slots:
void setCard(const ExactCard &card); void setCard(const ExactCard &card);
void setScaleFactor(int scale); // New slot for scaling void setScaleFactor(int scale); // New slot for scaling

View file

@ -0,0 +1,83 @@
#include "card_picture_delegate.h"
#include "../../../client/settings/cache_settings.h"
#include "../../card_picture_loader/card_picture_loader.h"
#include "card_art_utils.h"
#include "card_database_visual_display_model.h"
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/settings/cards_display_settings.h"
#include <QPainter>
#include <QPainterPath>
#include <QPixmap>
#include <QRect>
#include <QStyleOptionViewItem>
#include <libcockatrice/card/printing/printing_info.h>
CardPictureDelegate::CardPictureDelegate(CardDatabaseVisualDisplayModel *_model, QObject *parent)
: QStyledItemDelegate(parent), model(_model)
{
}
void CardPictureDelegate::setCardSize(const QSize &size)
{
if (cardSize == size) {
return;
}
cardSize = size;
}
void CardPictureDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
{
if (model == nullptr) {
return;
}
const ExactCard card = model->cardAt(index.row());
if (!card) {
return;
}
// The grid cell is the card plus SPACING; give the picture the rest.
const int inset = SPACING / 2;
const QRect cardArea = option.rect.adjusted(inset, inset, -inset, -inset);
QPixmap pixmap;
CardPictureLoader::getCardBackLoadingInProgressPixmap(pixmap, cardArea.size());
CardPictureLoader::getPixmap(pixmap, card, cardArea.size());
if (pixmap.isNull()) {
return;
}
if (SettingsCache::instance().cardsDisplay().getAutoRotateSidewaysLayoutCards()) {
pixmap = CardArtUtils::rotateSidewaysLayoutArt(pixmap, card);
}
// Fit the picture into the cell and center it, like CardInfoPictureWidget does.
const QSize pictureSize = pixmap.deviceIndependentSize().toSize().scaled(cardArea.size(), Qt::KeepAspectRatio);
const QRect pictureRect(cardArea.center().x() - pictureSize.width() / 2,
cardArea.center().y() - pictureSize.height() / 2, pictureSize.width(),
pictureSize.height());
const qreal radius = SettingsCache::instance().cardsDisplay().getRoundCardCorners()
? 0.05 * static_cast<qreal>(pictureRect.width())
: 0.0;
painter->save();
painter->setRenderHint(QPainter::Antialiasing);
painter->setRenderHint(QPainter::SmoothPixmapTransform);
QPainterPath shape;
shape.addRoundedRect(pictureRect, radius, radius);
painter->setClipPath(shape);
// Source rect is in device pixels, target in logical ones: drawPixmap scales
// between them without the device pixel ratio being applied twice.
painter->drawPixmap(pictureRect, pixmap, QRectF(pixmap.rect()));
painter->restore();
}
QSize CardPictureDelegate::sizeHint(const QStyleOptionViewItem & /*option*/, const QModelIndex & /*index*/) const
{
return cardSize + QSize(SPACING, SPACING);
}

View file

@ -0,0 +1,45 @@
/**
* @file card_picture_delegate.h
* @ingroup CardWidgets
* @brief Paints a card picture for a visual card grid item.
*/
#ifndef CARD_PICTURE_DELEGATE_H
#define CARD_PICTURE_DELEGATE_H
#include <QSize>
#include <QStyledItemDelegate>
#include <qtmetamacros.h>
class CardDatabaseVisualDisplayModel;
/**
* @brief Draws the card of a CardDatabaseVisualDisplayModel row as a picture.
*
* The delegate only paints rows the view asks for, so a grid of the whole card
* database stays cheap: no widget per card and no pixmap loaded for items that
* are off screen. Card images come from CardPictureLoader, which caches them and
* loads missing ones asynchronously.
*/
class CardPictureDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
/// Gap left around a card picture inside its grid cell, in pixels.
static constexpr int SPACING = 4;
explicit CardPictureDelegate(CardDatabaseVisualDisplayModel *_model, QObject *parent = nullptr);
/// The picture size inside a grid cell. The cell is this size plus SPACING.
void setCardSize(const QSize &size);
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
[[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
private:
CardDatabaseVisualDisplayModel *model;
QSize cardSize;
};
#endif // CARD_PICTURE_DELEGATE_H

View file

@ -2,6 +2,7 @@
#include "../general/layout_containers/flow_widget.h" #include "../general/layout_containers/flow_widget.h"
#include <QAbstractScrollArea>
#include <QEvent> #include <QEvent>
#include <QFlags> #include <QFlags>
#include <QHBoxLayout> #include <QHBoxLayout>
@ -33,7 +34,7 @@ CardSizeWidget::CardSizeWidget(QWidget *parent, FlowWidget *_flowWidget, int def
cardSizeLabel->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); cardSizeLabel->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
cardSizeSlider = new QSlider(Qt::Horizontal, this); cardSizeSlider = new QSlider(Qt::Horizontal, this);
cardSizeSlider->setRange(50, 250); ///< Slider range for card size adjustment. cardSizeSlider->setRange(MIN_CARD_SIZE, MAX_CARD_SIZE); ///< Slider range for card size adjustment.
cardSizeSlider->setValue(defaultValue); ///< Initial slider value. cardSizeSlider->setValue(defaultValue); ///< Initial slider value.
cardSizeLayout->addWidget(cardSizeLabel); cardSizeLayout->addWidget(cardSizeLabel);
@ -73,14 +74,27 @@ QSlider *CardSizeWidget::getSlider() const
void CardSizeWidget::enableCtrlScrollResize(QWidget *host) void CardSizeWidget::enableCtrlScrollResize(QWidget *host)
{ {
host->installEventFilter(this); const auto watchScrollArea = [this](QAbstractScrollArea *scrollArea) {
if (scrollArea == nullptr) {
const auto scrollAreas = host->findChildren<QScrollArea *>(); return;
for (QScrollArea *scrollArea : scrollAreas) { }
if (QWidget *content = scrollArea->widget()) { if (QWidget *viewport = scrollArea->viewport()) {
viewport->installEventFilter(this);
}
if (auto *contentScrollArea = qobject_cast<QScrollArea *>(scrollArea)) {
if (QWidget *content = contentScrollArea->widget()) {
content->installEventFilter(this); content->installEventFilter(this);
} }
} }
};
host->installEventFilter(this);
watchScrollArea(qobject_cast<QAbstractScrollArea *>(host));
const auto scrollAreas = host->findChildren<QAbstractScrollArea *>();
for (QAbstractScrollArea *scrollArea : scrollAreas) {
watchScrollArea(scrollArea);
}
} }
bool CardSizeWidget::eventFilter(QObject *watched, QEvent *event) bool CardSizeWidget::eventFilter(QObject *watched, QEvent *event)

View file

@ -24,6 +24,10 @@ class CardSizeWidget : public QWidget
Q_OBJECT Q_OBJECT
public: public:
/// Range of the card size slider, as a percentage of the base card width.
static constexpr int MIN_CARD_SIZE = 50;
static constexpr int MAX_CARD_SIZE = 250;
explicit CardSizeWidget(QWidget *parent, FlowWidget *flowWidget = nullptr, int defaultValue = 100); explicit CardSizeWidget(QWidget *parent, FlowWidget *flowWidget = nullptr, int defaultValue = 100);
[[nodiscard]] QSlider *getSlider() const; [[nodiscard]] QSlider *getSlider() const;
@ -31,8 +35,9 @@ public:
* @brief Resizes the cards when the user Ctrl + scrolls over @p host or any of its descendants. * @brief Resizes the cards when the user Ctrl + scrolls over @p host or any of its descendants.
* *
* Installs this widget as an event filter on the given host widget. If the host contains a * Installs this widget as an event filter on the given host widget. If the host contains a
* scroll area the filter is also installed on the scroll area's content widget, so the resize * scroll area the filter is also installed on the scroll area's content widget, and on the
* intercepts the wheel event before the scroll area would scroll the view. * viewport of every scroll area base (QListView, QTableView, ...), so the resize intercepts
* the wheel event before the scroll area would scroll the view.
*/ */
void enableCtrlScrollResize(QWidget *host); void enableCtrlScrollResize(QWidget *host);

View file

@ -0,0 +1,203 @@
#include "card_database_visual_view.h"
#include "../../card_picture_loader/card_picture_loader.h"
#include "../cards/card_info_picture_widget.h"
#include "../cards/card_picture_delegate.h"
#include "../cards/card_size_widget.h"
#include "card_database_visual_display_model.h"
#include "libcockatrice/card/printing/exact_card.h"
#include <QAbstractItemModel>
#include <QAbstractItemView>
#include <QApplication>
#include <QCursor>
#include <QEvent>
#include <QFrame>
#include <QModelIndex>
#include <QMouseEvent>
#include <QScrollBar>
#include <QSize>
#include <QWheelEvent>
#include <QWidget>
#include <QtGlobal>
#include <qnamespace.h>
CardDatabaseVisualView::CardDatabaseVisualView(CardDatabaseVisualDisplayModel *_model, QWidget *parent)
: QListView(parent), model(_model)
{
auto *cardDelegate = new CardPictureDelegate(model, this);
setItemDelegate(cardDelegate);
setModel(model);
setViewMode(QListView::IconMode);
setFlow(QListView::LeftToRight);
setWrapping(true);
setResizeMode(QListView::Adjust);
setMovement(QListView::Static);
setUniformItemSizes(true);
setLayoutMode(QListView::SinglePass);
setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
setFrameShape(QFrame::NoFrame);
setWordWrap(false);
// QListView hands the delegate's item height to the scroll bar as its single
// step under ScrollPerPixel, so one wheel notch would jump wheelScrollLines()
// whole card rows. Give the bar a text line instead; the wheel handler below
// scales that line to the grid. A positive step also stops Qt from taking the
// item height back on the next layout.
verticalScrollBar()->setSingleStep(qMax(1, fontMetrics().height()));
// The grid is a read-only surface: clicks are actions on a card, not a
// selection, and they must not steal focus from the search field.
setSelectionMode(QAbstractItemView::NoSelection);
setEditTriggers(QAbstractItemView::NoEditTriggers);
setFocusPolicy(Qt::NoFocus);
// Hover drives the card preview.
setMouseTracking(true);
viewport()->setMouseTracking(true);
connect(&CardPictureLoader::getInstance(), &CardPictureLoader::pixmapLoaded, this,
[this] { viewport()->update(); });
// A new search result starts at the top, like the widget-per-card grid did.
connect(model, &QAbstractItemModel::modelReset, this, [this] {
verticalScrollBar()->setValue(0);
// The rows under a stationary cursor changed with the reset: re-resolve the
// hover so the preview follows the grid, as enterEvent did per card widget.
hoveredRow = -1;
updateHoveredRow(viewport()->mapFromGlobal(QCursor::pos()));
});
setCardSizeFactor(cardSizeFactor);
}
void CardDatabaseVisualView::setCardSizeFactor(int factor)
{
cardSizeFactor = qBound(CardSizeWidget::MIN_CARD_SIZE, factor, CardSizeWidget::MAX_CARD_SIZE);
const QSize cardSize = CardInfoPictureWidget::sizeForScaleFactor(cardSizeFactor);
if (auto *cardDelegate = qobject_cast<CardPictureDelegate *>(itemDelegate())) {
cardDelegate->setCardSize(cardSize);
}
// The delegate's sizeHint does not emit sizeHintChanged, so the grid size is what
// makes a resize take effect. Keep the two in step.
setGridSize(cardSize + QSize(CardPictureDelegate::SPACING, CardPictureDelegate::SPACING));
}
void CardDatabaseVisualView::mousePressEvent(QMouseEvent *event)
{
const QModelIndex index = indexAt(event->pos());
if (index.isValid() && model != nullptr) {
const ExactCard card = model->cardAt(index.row());
if (card) {
emit cardClicked(event, card);
event->accept();
return;
}
}
QListView::mousePressEvent(event);
}
void CardDatabaseVisualView::mouseMoveEvent(QMouseEvent *event)
{
QListView::mouseMoveEvent(event);
updateHoveredRow(event->pos());
}
bool CardDatabaseVisualView::viewportEvent(QEvent *event)
{
// A per-card widget reported its hover from enterEvent, which also fires when the
// grid changes under a stationary cursor. A QListView only reports moves, so
// re-resolve the hover on enter and clear it on leave.
if (event->type() == QEvent::Enter) {
updateHoveredRow(viewport()->mapFromGlobal(QCursor::pos()));
} else if (event->type() == QEvent::Leave) {
hoveredRow = -1;
}
return QListView::viewportEvent(event);
}
void CardDatabaseVisualView::wheelEvent(QWheelEvent *event)
{
// Ctrl+wheel is the card-size gesture and is filtered out before it reaches
// the view; any other modifier keeps QAbstractSlider's own page scrolling.
if (event->modifiers() != Qt::NoModifier) {
QListView::wheelEvent(event);
return;
}
QScrollBar *bar = verticalScrollBar();
if (!bar->isVisible()) {
event->ignore();
return;
}
// Touchpads and high-resolution wheels already report pixels: follow them.
if (!event->pixelDelta().isNull()) {
bar->setValue(bar->value() - event->pixelDelta().y());
event->accept();
return;
}
if (event->angleDelta().y() == 0) {
QListView::wheelEvent(event);
return;
}
// Accumulate wheel deltas smaller than a notch, like QAbstractSlider does, so
// a fine-grained wheel still moves.
wheelRemainder += static_cast<qreal>(event->angleDelta().y()) / 120.0;
const int notches = static_cast<int>(wheelRemainder);
if (notches == 0) {
event->accept();
return;
}
wheelRemainder -= notches;
const int pixels = -notches * wheelStep();
bar->setValue(qBound(bar->minimum(), bar->value() + pixels, bar->maximum()));
event->accept();
}
int CardDatabaseVisualView::wheelStep() const
{
// A card row is far too coarse for one wheel line, and a text line is too
// fine, so a line is an eighth of the grid cell. A notch then moves about a
// third of a card row at any card size, and the OS line count still applies.
const int lineHeight = qMax(1, gridSize().height() / 8);
return qMax(1, QApplication::wheelScrollLines()) * lineHeight;
}
void CardDatabaseVisualView::updateHoveredRow(const QPoint &position)
{
// indexAt() also answers for points outside the viewport, so a cursor parked
// below the grid (on the size slider, say) would resolve to the row just under
// the visible area. Only a point inside the viewport can hover a card.
if (model == nullptr || !viewport()->rect().contains(position)) {
return;
}
const QModelIndex index = indexAt(position);
const int row = index.isValid() ? index.row() : -1;
if (row == hoveredRow) {
return;
}
hoveredRow = row;
if (row >= 0) {
const ExactCard card = model->cardAt(row);
if (card) {
emit cardHovered(card);
}
}
}
void CardDatabaseVisualView::leaveEvent(QEvent *event)
{
hoveredRow = -1;
QListView::leaveEvent(event);
}

View file

@ -0,0 +1,67 @@
/**
* @file card_database_visual_view.h
* @ingroup VisualCardDatabaseWidgets
* @brief Icon-mode view of the card database, one card picture per item.
*/
#ifndef CARD_DATABASE_VISUAL_VIEW_H
#define CARD_DATABASE_VISUAL_VIEW_H
#include <QListView>
#include <QSize>
#include <qtmetamacros.h>
class CardDatabaseVisualDisplayModel;
class ExactCard;
class QEvent;
class QMouseEvent;
class QPoint;
class QWheelEvent;
/**
* @brief A wrapping grid of card pictures.
*
* The view virtualizes painting: it only asks the delegate for the items that are
* on screen, so the whole card database can sit in the model without a widget (or
* pixmap) per card. Clicks and hover are reported with the exact card under the
* cursor, which is what the deck editor needs to add, decrement or preview a card.
*/
class CardDatabaseVisualView : public QListView
{
Q_OBJECT
public:
explicit CardDatabaseVisualView(CardDatabaseVisualDisplayModel *_model, QWidget *parent = nullptr);
/**
* @brief Sets the card size as a percentage of the base card width.
* @param factor Percentage in the range of the card size slider.
*/
void setCardSizeFactor(int factor);
signals:
/** @brief A card was pressed. The event is valid for the duration of the emission. */
void cardClicked(QMouseEvent *event, const ExactCard &card);
/** @brief The cursor moved onto a different card. */
void cardHovered(const ExactCard &card);
protected:
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void leaveEvent(QEvent *event) override;
bool viewportEvent(QEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
private:
void updateHoveredRow(const QPoint &position);
/// Pixels one wheel notch moves, scaled to the grid and the OS line count.
[[nodiscard]] int wheelStep() const;
CardDatabaseVisualDisplayModel *model;
/// Fraction of a line left over from a wheel event smaller than one notch.
qreal wheelRemainder = 0.0;
int cardSizeFactor = 100;
int hoveredRow = -1;
};
#endif // CARD_DATABASE_VISUAL_VIEW_H

View file

@ -5,40 +5,29 @@
#include "../../../filters/syntax_help.h" #include "../../../filters/syntax_help.h"
#include "../../key_signals.h" #include "../../key_signals.h"
#include "../../pixel_map_generator.h" #include "../../pixel_map_generator.h"
#include "../cards/card_info_picture_widget.h"
#include "../cards/card_info_picture_with_text_overlay_widget.h"
#include "../cards/card_size_widget.h" #include "../cards/card_size_widget.h"
#include "../deck_editor/card_database_view.h" #include "../deck_editor/card_database_view.h"
#include "../general/layout_containers/flow_widget.h" #include "../general/layout_containers/flow_widget.h"
#include "../utility/custom_line_edit.h" #include "../utility/custom_line_edit.h"
#include "card_database_display_model.h" #include "card_database_display_model.h"
#include "card_database_model.h" #include "card_database_model.h"
#include "card_database_visual_display_model.h"
#include "card_database_visual_view.h"
#include "deck_list_model.h" #include "deck_list_model.h"
#include "libcockatrice/card/card_localization.h" #include "libcockatrice/card/card_localization.h"
#include "libcockatrice/card/database/card_database_loader.h" #include "libcockatrice/card/database/card_database_loader.h"
#include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/card/printing/printing_info.h"
#include "libcockatrice/filters/filter_card.h" #include "libcockatrice/filters/filter_card.h"
#include "libcockatrice/filters/filter_tree.h" #include "libcockatrice/filters/filter_tree.h"
#include "visual_database_display_color_filter_widget.h" #include "visual_database_display_color_filter_widget.h"
#include "visual_database_display_filter_toolbar_widget.h" #include "visual_database_display_filter_toolbar_widget.h"
#include <QAbstractItemModel>
#include <QAction> #include <QAction>
#include <QDebug>
#include <QIcon> #include <QIcon>
#include <QLabel> #include <QLabel>
#include <QLineEdit> #include <QLineEdit>
#include <QList>
#include <QMessageLogger>
#include <QModelIndex>
#include <QPair>
#include <QPersistentModelIndex>
#include <QPushButton> #include <QPushButton>
#include <QScrollArea>
#include <QScrollBar>
#include <QSharedPointer>
#include <QSizePolicy> #include <QSizePolicy>
#include <QSlider> #include <QSlider>
#include <QStringLiteral> #include <QStringLiteral>
@ -48,14 +37,11 @@
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QVariant> #include <QVariant>
#include <QtGlobal> #include <QtGlobal>
#include <algorithm>
#include <libcockatrice/card/card_info_comparator.h>
#include <libcockatrice/card/database/card_database.h> #include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h> #include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h> #include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
#include <libcockatrice/settings/cards_display_settings.h> #include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/utility/qt_utils.h> #include <libcockatrice/utility/qt_utils.h>
#include <utility>
class QMouseEvent; class QMouseEvent;
@ -64,11 +50,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
DeckListModel *deckListModel) DeckListModel *deckListModel)
: QWidget(parent) : QWidget(parent)
{ {
debounceTimer = new QTimer(this);
debounceTimer->setSingleShot(true); // Ensure it only fires once after the timeout
connect(debounceTimer, &QTimer::timeout, this, &VisualDatabaseDisplayWidget::onSearchModelChanged);
// Create display model // Create display model
databaseDisplayModel = new CardDatabaseDisplayModel(this); databaseDisplayModel = new CardDatabaseDisplayModel(this);
databaseDisplayModel->setObjectName("databaseDisplayModel"); databaseDisplayModel->setObjectName("databaseDisplayModel");
@ -86,23 +67,12 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this, connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this,
applyCardSearchLanguage); applyCardSearchLanguage);
cards = new QList<ExactCard>;
connect(databaseDisplayModel, &CardDatabaseDisplayModel::modelDirty, this,
&VisualDatabaseDisplayWidget::modelDirty);
// Set up main layout and widgets // Set up main layout and widgets
setMinimumSize(0, 0); setMinimumSize(0, 0);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
mainLayout = QtUtils::makeLayout<QVBoxLayout>(this, 0, 1); mainLayout = QtUtils::makeLayout<QVBoxLayout>(this, 0, 1);
setLayout(mainLayout); setLayout(mainLayout);
flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarPolicy::ScrollBarAsNeeded);
cardSizeWidget = new CardSizeWidget(this, flowWidget,
SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize());
connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(),
&CardsDisplaySettings::setVisualDatabaseDisplayCardSize);
cardSizeWidget->enableCtrlScrollResize(flowWidget);
searchContainer = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff); searchContainer = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff);
searchEdit = new SearchLineEdit(); searchEdit = new SearchLineEdit();
@ -159,6 +129,25 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
connect(databaseView, &CardDatabaseView::relatedCardClicked, this, connect(databaseView, &CardDatabaseView::relatedCardClicked, this,
&VisualDatabaseDisplayWidget::onRelatedCardClicked); &VisualDatabaseDisplayWidget::onRelatedCardClicked);
// The visual grid is a real view on a flattened model: one row per displayed
// card picture, painted on demand.
visualDisplayModel = new CardDatabaseVisualDisplayModel(databaseDisplayModel, CardDatabaseManager::query(), this);
cardView = new CardDatabaseVisualView(visualDisplayModel, this);
cardView->setObjectName("visualCardView");
connect(cardView, &CardDatabaseVisualView::cardClicked, this,
&VisualDatabaseDisplayWidget::cardClickedDatabaseDisplay);
connect(cardView, &CardDatabaseVisualView::cardHovered, this,
&VisualDatabaseDisplayWidget::cardHoveredDatabaseDisplay);
const int cardSize = SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize();
cardSizeWidget = new CardSizeWidget(this, nullptr, cardSize);
connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(),
&CardsDisplaySettings::setVisualDatabaseDisplayCardSize);
connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, cardView, &CardDatabaseVisualView::setCardSizeFactor);
cardView->setCardSizeFactor(cardSize);
cardSizeWidget->enableCtrlScrollResize(cardView);
colorFilterWidget = new VisualDatabaseDisplayColorFilterWidget(this, filterModel); colorFilterWidget = new VisualDatabaseDisplayColorFilterWidget(this, filterModel);
filterContainer = new VisualDatabaseDisplayFilterToolbarWidget(this, deckListModel); filterContainer = new VisualDatabaseDisplayFilterToolbarWidget(this, deckListModel);
@ -190,8 +179,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
databaseLoadIndicator->setVisible(false); databaseLoadIndicator->setVisible(false);
} }
QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar();
connect(scrollBar, &QScrollBar::valueChanged, [this](int /*value*/) { loadCurrentPage(); });
retranslateUi(); retranslateUi();
} }
@ -210,7 +197,7 @@ void VisualDatabaseDisplayWidget::initialize()
mainLayout->addWidget(databaseView); mainLayout->addWidget(databaseView);
mainLayout->addWidget(flowWidget); mainLayout->addWidget(cardView);
mainLayout->addWidget(cardSizeWidget); mainLayout->addWidget(cardSizeWidget);
@ -241,8 +228,6 @@ void VisualDatabaseDisplayWidget::initializeFilters()
databaseDisplayModel->setFilterTree(filterModel->filterTree()); databaseDisplayModel->setFilterTree(filterModel->filterTree());
QTimer::singleShot(5000, this, [this] { loadCurrentPage(); });
retranslateUi(); retranslateUi();
}); });
} }
@ -278,71 +263,36 @@ void VisualDatabaseDisplayWidget::updateDisplayModeButton()
displayModeButton->setText(displayModeButton->isChecked() ? tr("Table") : tr("Visual")); displayModeButton->setText(displayModeButton->isChecked() ? tr("Table") : tr("Visual"));
} }
void VisualDatabaseDisplayWidget::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
loadCurrentPage();
}
void VisualDatabaseDisplayWidget::onDisplayModeChanged(bool checked) void VisualDatabaseDisplayWidget::onDisplayModeChanged(bool checked)
{ {
updateDisplayModeButton(); updateDisplayModeButton();
if (checked) { if (checked) {
// Table mode // Table mode
flowWidget->setVisible(false); cardView->setVisible(false);
cardSizeWidget->setVisible(false); cardSizeWidget->setVisible(false);
databaseView->setItemDelegate(new QStyledItemDelegate(databaseView)); databaseView->setItemDelegate(new QStyledItemDelegate(databaseView));
databaseView->setVisible(true); databaseView->setVisible(true);
} else { } else {
// Visual mode // Visual mode
flowWidget->setVisible(true); cardView->setVisible(true);
cardSizeWidget->setVisible(true); cardSizeWidget->setVisible(true);
databaseView->setVisible(false); databaseView->setVisible(false);
populateCards();
} }
} }
void VisualDatabaseDisplayWidget::onClick(QMouseEvent *event, const ExactCard &card)
{
emit cardClickedDatabaseDisplay(event, card);
}
void VisualDatabaseDisplayWidget::onHover(const ExactCard &hoveredCard)
{
emit cardHoveredDatabaseDisplay(hoveredCard);
}
void VisualDatabaseDisplayWidget::addCardToDisplay(const ExactCard &cardToAdd)
{
cards->append(cardToAdd);
auto *display = new CardInfoPictureWithTextOverlayWidget(flowWidget, false);
display->setScaleFactor(cardSizeWidget->getSlider()->value());
display->setCard(cardToAdd);
flowWidget->addWidget(display);
connect(display, &CardInfoPictureWithTextOverlayWidget::cardClicked, this, &VisualDatabaseDisplayWidget::onClick);
connect(display, &CardInfoPictureWithTextOverlayWidget::hoveredOnCard, this, &VisualDatabaseDisplayWidget::onHover);
connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, display, &CardInfoPictureWidget::setScaleFactor);
}
bool VisualDatabaseDisplayWidget::isVisualDisplayMode() const bool VisualDatabaseDisplayWidget::isVisualDisplayMode() const
{ {
return !displayModeButton->isChecked(); return !displayModeButton->isChecked();
} }
QStringList VisualDatabaseDisplayWidget::collectSetFilterTerms() const
{
return filterModel->getFilterTermsOfType(CardFilter::AttrSet);
}
void VisualDatabaseDisplayWidget::onSearchModelChanged() void VisualDatabaseDisplayWidget::onSearchModelChanged()
{ {
if (flowWidget->isVisible()) { visualDisplayModel->setSetFilterTerms(collectSetFilterTerms());
// Clear the current page and prepare for new data
flowWidget->clearLayout(); // Clear existing cards
cards->clear(); // Clear the card list
// Reset scrollbar position to the top after loading new cards
QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar();
scrollBar->setValue(0); // Reset scrollbar to top
currentPage = 0;
loadCurrentPage();
qCDebug(VisualDatabaseDisplayLog) << "Search model changed";
}
} }
void VisualDatabaseDisplayWidget::onSelectedCardChanged(const QString &cardName) void VisualDatabaseDisplayWidget::onSelectedCardChanged(const QString &cardName)
@ -368,108 +318,3 @@ void VisualDatabaseDisplayWidget::onRelatedCardClicked(const QString &relatedCar
ExactCard exactCard = CardDatabaseManager::query()->guessCard({relatedCard}); ExactCard exactCard = CardDatabaseManager::query()->guessCard({relatedCard});
emit cardInfoRequested(exactCard); emit cardInfoRequested(exactCard);
} }
bool VisualDatabaseDisplayWidget::nearEndOfPage() const
{
if (!flowWidget->isVisible()) {
return false;
}
QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar();
if (scrollBar->value() + scrollBar->pageStep() * 2 < scrollBar->maximum()) {
return false; // there is at least two pages of space to scroll remaining
}
return true;
}
void VisualDatabaseDisplayWidget::loadCurrentPage()
{
// Ensure only the initial page is loaded
if (currentPage == 0) {
if (!initialLoadScheduled) {
initialLoadScheduled = true;
qCDebug(VisualDatabaseDisplayLog) << "Loading the first page";
// Defer the first page so the tab switch stays responsive. The card
// grid builds one event loop turn later. This also applies to
// search-driven reloads, which reset currentPage back to 0.
QTimer::singleShot(0, this, [this] {
initialLoadScheduled = false;
populateCards();
});
}
} else if (nearEndOfPage()) {
// If not the first page, just load the next page and append to the flow widget
loadNextPage();
}
}
void VisualDatabaseDisplayWidget::populateCards()
{
cards->clear();
loadNextPage();
}
void VisualDatabaseDisplayWidget::loadNextPage()
{
// Calculate the start and end indices for the next page
int rowCount = databaseDisplayModel->rowCount();
int start = currentPage * cardsPerPage;
int end = qMin(start + cardsPerPage, rowCount);
// Load the next page of cards and add them to the flow widget
flowWidget->setUpdatesEnabled(false);
loadPage(start, end);
flowWidget->setUpdatesEnabled(true);
}
void VisualDatabaseDisplayWidget::loadPage(int start, int end)
{
QList<const CardFilter *> setFilters = filterModel->getFiltersOfType(CardFilter::AttrSet);
for (int row = start; row < end; ++row) {
qCDebug(VisualDatabaseDisplayLog) << "Adding " << row;
QModelIndex index = databaseDisplayModel->index(row, CardDatabaseModel::NameColumn);
QVariant name = databaseDisplayModel->data(index, Qt::DisplayRole);
qCDebug(VisualDatabaseDisplayLog) << name.toString();
if (CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(name.toString())) {
if (!setFilters.empty()) {
const auto &setMap = info->getSets();
for (const CardFilter *setFilter : setFilters) {
const auto it = setMap.constFind(setFilter->term());
if (it != setMap.constEnd()) {
for (const PrintingInfo &printing : it.value()) {
addCardToDisplay(ExactCard(info, printing));
}
}
}
} else {
addCardToDisplay(CardDatabaseManager::query()->getPreferredCard(info));
}
} else {
qCDebug(VisualDatabaseDisplayLog) << "Card not found in database!";
}
}
currentPage++;
}
void VisualDatabaseDisplayWidget::modelDirty() const
{
debounceTimer->start(debounceTime);
}
void VisualDatabaseDisplayWidget::sortCardList(const QStringList &properties,
Qt::SortOrder order = Qt::AscendingOrder) const
{
CardInfoComparator comparator(properties, order);
std::sort(cards->begin(), cards->end(), [comparator](const ExactCard &a, const ExactCard &b) {
return comparator(a.getCardPtr(), b.getCardPtr());
});
}
void VisualDatabaseDisplayWidget::databaseDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight)
{
(void)topLeft;
(void)bottomRight;
qCDebug(VisualDatabaseDisplayLog) << "Database Data changed";
}

View file

@ -9,8 +9,6 @@
#include "libcockatrice/card/card_info.h" #include "libcockatrice/card/card_info.h"
#include <QLoggingCategory>
#include <QMouseEvent>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QWidget> #include <QWidget>
@ -21,26 +19,21 @@
class CardDatabaseDisplayModel; class CardDatabaseDisplayModel;
class CardDatabaseModel; class CardDatabaseModel;
class CardDatabaseView; class CardDatabaseView;
class CardDatabaseVisualDisplayModel;
class CardDatabaseVisualView;
class CardSizeWidget; class CardSizeWidget;
class DeckList;
class DeckListModel; class DeckListModel;
class ExactCard; class ExactCard;
class FilterTreeModel; class FilterTreeModel;
class FlowWidget; class FlowWidget;
class OverlapControlWidget;
class QLabel; class QLabel;
class QModelIndex; class QMouseEvent;
class QPushButton; class QPushButton;
class QScrollArea;
class QTimer;
class QToolButton; class QToolButton;
class QVBoxLayout; class QVBoxLayout;
class SearchLineEdit; class SearchLineEdit;
class VisualDatabaseDisplayColorFilterWidget; class VisualDatabaseDisplayColorFilterWidget;
class VisualDatabaseDisplayFilterToolbarWidget; class VisualDatabaseDisplayFilterToolbarWidget;
template <typename T> class QList;
inline Q_LOGGING_CATEGORY(VisualDatabaseDisplayLog, "visual_database_display");
class VisualDatabaseDisplayWidget : public QWidget class VisualDatabaseDisplayWidget : public QWidget
{ {
@ -52,14 +45,6 @@ public:
DeckListModel *deckListModel = nullptr); DeckListModel *deckListModel = nullptr);
void retranslateUi(); void retranslateUi();
void adjustCardsPerPage();
void populateCards();
void loadPage(int start, int end);
void loadNextPage();
void loadCurrentPage();
void sortCardList(const QStringList &properties, Qt::SortOrder order) const;
void setDeckList(const DeckList &new_deck_list_model);
/** /**
* @brief Sets the callback used to create a custom zone from the add-to-zone menu. * @brief Sets the callback used to create a custom zone from the add-to-zone menu.
* The callback returns the name of the created zone, or an empty string if creation was cancelled. * The callback returns the name of the created zone, or an empty string if creation was cancelled.
@ -87,6 +72,13 @@ public:
bool isVisualDisplayMode() const; bool isVisualDisplayMode() const;
public slots: public slots:
/**
* @brief Pushes the active set filters to the visual grid.
*
* The grid's rows follow the display model's filter and sort signals on their
* own; this only forwards the set filters, which decide which printings the
* grid shows.
*/
void onSearchModelChanged(); void onSearchModelChanged();
signals: signals:
@ -101,11 +93,6 @@ signals:
protected slots: protected slots:
void initialize(); void initialize();
void onClick(QMouseEvent *event, const ExactCard &card);
void onHover(const ExactCard &hoveredCard);
void addCardToDisplay(const ExactCard &cardToAdd);
void databaseDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight);
void modelDirty() const;
void onDisplayModeChanged(bool checked); void onDisplayModeChanged(bool checked);
void onSelectedCardChanged(const QString &cardName); void onSelectedCardChanged(const QString &cardName);
@ -125,31 +112,21 @@ private:
QToolButton *clearFilterWidget; QToolButton *clearFilterWidget;
VisualDatabaseDisplayFilterToolbarWidget *filterContainer; VisualDatabaseDisplayFilterToolbarWidget *filterContainer;
CardDatabaseDisplayModel *databaseDisplayModel; CardDatabaseDisplayModel *databaseDisplayModel;
CardDatabaseVisualDisplayModel *visualDisplayModel;
CardDatabaseView *databaseView; CardDatabaseView *databaseView;
CardDatabaseVisualView *cardView;
std::function<QString()> newZoneCreator; std::function<QString()> newZoneCreator;
QList<ExactCard> *cards;
QVBoxLayout *mainLayout; QVBoxLayout *mainLayout;
QScrollArea *scrollArea;
FlowWidget *flowWidget;
QWidget *overlapCategories;
QVBoxLayout *overlapCategoriesLayout;
OverlapControlWidget *overlapControlWidget;
CardSizeWidget *cardSizeWidget; CardSizeWidget *cardSizeWidget;
QTimer *debounceTimer;
int debounceTime = 300; // in Ms
int currentPage = 0; // Current page index
int cardsPerPage = 100; // Number of cards per page
bool filtersInitialized = false; bool filtersInitialized = false;
bool initialLoadScheduled = false;
void initializeFilters(); void initializeFilters();
void highlightAllSearchEdit(); void highlightAllSearchEdit();
void updateDisplayModeButton(); void updateDisplayModeButton();
bool nearEndOfPage() const; [[nodiscard]] QStringList collectSetFilterTerms() const;
protected: protected:
void resizeEvent(QResizeEvent *event) override;
void showEvent(QShowEvent *event) override; void showEvent(QShowEvent *event) override;
}; };

View file

@ -54,7 +54,7 @@ private:
enum Attr a; enum Attr a;
public: public:
CardFilter(QString &term, Type type, Attr attr) : trm(term), t(type), a(attr) CardFilter(const QString &term, Type type, Attr attr) : trm(term), t(type), a(attr)
{ {
} }

View file

@ -1,6 +1,7 @@
set(HEADERS set(HEADERS
card_database_model.h card_database_model.h
card_database_display_model.h card_database_display_model.h
card_database_visual_display_model.h
card/card_completer_proxy_model.h card/card_completer_proxy_model.h
card/card_search_model.h card/card_search_model.h
card_set/card_sets_model.h card_set/card_sets_model.h
@ -13,6 +14,7 @@ add_library(
${HEADERS} ${HEADERS}
card_database_model.cpp card_database_model.cpp
card_database_display_model.cpp card_database_display_model.cpp
card_database_visual_display_model.cpp
card/card_completer_proxy_model.cpp card/card_completer_proxy_model.cpp
card/card_search_model.cpp card/card_search_model.cpp
card_set/card_sets_model.cpp card_set/card_sets_model.cpp

View file

@ -21,7 +21,7 @@
class QObject; class QObject;
CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent) CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent)
: QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr), : QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr), cardModel(nullptr),
collator(NaturalSort::makeNaturalSortCollator()) collator(NaturalSort::makeNaturalSortCollator())
{ {
filterTree = nullptr; filterTree = nullptr;
@ -37,6 +37,7 @@ CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent)
void CardDatabaseDisplayModel::setSourceModel(QAbstractItemModel *model) void CardDatabaseDisplayModel::setSourceModel(QAbstractItemModel *model)
{ {
cardModel = qobject_cast<CardDatabaseModel *>(model);
QSortFilterProxyModel::setSourceModel(model); QSortFilterProxyModel::setSourceModel(model);
connect(model, &QAbstractItemModel::rowsInserted, this, [this]() { dirty(); }); connect(model, &QAbstractItemModel::rowsInserted, this, [this]() { dirty(); });
@ -46,6 +47,25 @@ void CardDatabaseDisplayModel::setSourceModel(QAbstractItemModel *model)
connect(model, &QAbstractItemModel::modelReset, this, [this]() { dirty(); }); connect(model, &QAbstractItemModel::modelReset, this, [this]() { dirty(); });
} }
QVariant CardDatabaseDisplayModel::data(const QModelIndex &index, int role) const
{
if (role == CardInfoRole) {
return QVariant::fromValue(cardInfoAt(index));
}
return QSortFilterProxyModel::data(index, role);
}
CardInfoPtr CardDatabaseDisplayModel::cardInfoAt(const QModelIndex &index) const
{
if (!index.isValid() || cardModel == nullptr) {
return {};
}
const QModelIndex sourceIndex = mapToSource(index);
return sourceIndex.isValid() ? cardModel->getCard(sourceIndex.row()) : CardInfoPtr();
}
QMap<wchar_t, wchar_t> CardDatabaseDisplayModel::characterTranslation = {{L'“', L'\"'}, QMap<wchar_t, wchar_t> CardDatabaseDisplayModel::characterTranslation = {{L'“', L'\"'},
{L'”', L'\"'}, {L'”', L'\"'},
{L'‘', L'\''}, {L'‘', L'\''},

View file

@ -21,6 +21,7 @@
#include <libcockatrice/filters/filter_string.h> #include <libcockatrice/filters/filter_string.h>
#include <qtmetamacros.h> #include <qtmetamacros.h>
class CardDatabaseModel;
class FilterTree; class FilterTree;
class QObject; class QObject;
template <class Key, class T> class QMap; template <class Key, class T> class QMap;
@ -36,12 +37,25 @@ public:
ShowAll ShowAll
}; };
enum Role
{
/**
* @brief The CardInfoPtr behind a proxy row.
*
* Saves views from looking the card up again through its name, and keeps
* them from depending on the source model's column layout.
*/
CardInfoRole = Qt::UserRole + 1
};
private: private:
FilterBool isToken; FilterBool isToken;
QString cardName, cardText; QString cardName, cardText;
QSet<QString> cardNameSet, cardTypes, cardColors; QSet<QString> cardNameSet, cardTypes, cardColors;
FilterTree *filterTree; FilterTree *filterTree;
FilterString *filterString; FilterString *filterString;
/// The source model, kept typed so CardInfoRole does not cast per row.
CardDatabaseModel *cardModel;
QTimer dirtyTimer; QTimer dirtyTimer;
CardSearchLanguage searchLanguage; CardSearchLanguage searchLanguage;
QString searchText; QString searchText;
@ -84,6 +98,16 @@ public:
dirtyTimer.start(20); dirtyTimer.start(20);
} }
void clearFilterAll(); void clearFilterAll();
[[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
/**
* @brief Returns the card behind a proxy row, without the QVariant wrapper of CardInfoRole.
*
* The visual grid calls this for every row on each rebuild; CardInfoRole stays the
* generic way in for consumers that only see the model through data().
*/
[[nodiscard]] CardInfoPtr cardInfoAt(const QModelIndex &index) const;
signals: signals:
void modelDirty(); void modelDirty();

View file

@ -0,0 +1,123 @@
#include "card_database_visual_display_model.h"
#include "card_database_display_model.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include <QAbstractItemModel>
#include <QList>
#include <QStringList>
#include <QVariant>
/// Coalesces the bursts of proxy signals a filter, sort or database load produces.
static constexpr int REBUILD_COALESCE_MS = 30;
CardDatabaseVisualDisplayModel::CardDatabaseVisualDisplayModel(CardDatabaseDisplayModel *_displayModel,
CardDatabaseQuerier *_querier,
QObject *parent)
: QAbstractListModel(parent), displayModel(_displayModel), querier(_querier)
{
rebuildTimer.setSingleShot(true);
rebuildTimer.setInterval(REBUILD_COALESCE_MS);
connect(&rebuildTimer, &QTimer::timeout, this, &CardDatabaseVisualDisplayModel::rebuild);
if (displayModel == nullptr) {
return;
}
connect(displayModel, &QAbstractItemModel::modelReset, this, &CardDatabaseVisualDisplayModel::scheduleRebuild);
connect(displayModel, &QAbstractItemModel::layoutChanged, this, &CardDatabaseVisualDisplayModel::scheduleRebuild);
connect(displayModel, &QAbstractItemModel::rowsInserted, this, &CardDatabaseVisualDisplayModel::scheduleRebuild);
connect(displayModel, &QAbstractItemModel::rowsRemoved, this, &CardDatabaseVisualDisplayModel::scheduleRebuild);
connect(displayModel, &QAbstractItemModel::dataChanged, this, &CardDatabaseVisualDisplayModel::scheduleRebuild);
}
void CardDatabaseVisualDisplayModel::setSetFilterTerms(const QStringList &terms)
{
if (setFilterTerms == terms) {
return;
}
setFilterTerms = terms;
refresh();
}
void CardDatabaseVisualDisplayModel::refresh()
{
rebuildTimer.stop();
rebuild();
}
void CardDatabaseVisualDisplayModel::scheduleRebuild()
{
rebuildTimer.start();
}
void CardDatabaseVisualDisplayModel::rebuild()
{
beginResetModel();
entries.clear();
if (displayModel != nullptr) {
const int proxyRows = displayModel->rowCount();
entries.reserve(proxyRows);
for (int row = 0; row < proxyRows; ++row) {
const CardInfoPtr info = displayModel->cardInfoAt(displayModel->index(row, 0));
if (info) {
appendPrintingsFor(info);
}
}
}
endResetModel();
}
void CardDatabaseVisualDisplayModel::appendPrintingsFor(const CardInfoPtr &info)
{
if (setFilterTerms.isEmpty()) {
entries.append({info, PrintingInfo()});
return;
}
const auto &printingsBySet = info->getSets();
for (const QString &term : setFilterTerms) {
const auto printings = printingsBySet.constFind(term);
if (printings == printingsBySet.constEnd()) {
continue;
}
for (const PrintingInfo &printing : printings.value()) {
entries.append({info, printing});
}
}
}
int CardDatabaseVisualDisplayModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : static_cast<int>(entries.size());
}
QVariant CardDatabaseVisualDisplayModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= entries.size()) {
return {};
}
if (role == Qt::DisplayRole || role == Qt::AccessibleTextRole) {
return cardAt(index.row()).getName();
}
return {};
}
ExactCard CardDatabaseVisualDisplayModel::cardAt(int row) const
{
if (row < 0 || row >= entries.size()) {
return {};
}
const Entry &entry = entries.at(row);
if (entry.printing.isEmpty() && setFilterTerms.isEmpty()) {
return querier != nullptr ? querier->getPreferredCard(entry.info) : ExactCard(entry.info);
}
return ExactCard(entry.info, entry.printing);
}

View file

@ -0,0 +1,90 @@
/**
* @file card_database_visual_display_model.h
* @ingroup CardDatabaseModels
* @brief A list model of the exact cards a visual card grid paints.
*
* The filtered and sorted CardDatabaseDisplayModel holds one row per card name.
* A visual grid shows one picture per *printing* instead, so this model flattens
* the proxy rows into ExactCards: the preferred printing per card normally, and
* every printing of the selected sets while set filters are active.
*/
#ifndef COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H
#define COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H
#include "libcockatrice/card/card_info.h"
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/card/printing/printing_info.h"
#include <QAbstractListModel>
#include <QList>
#include <QModelIndex>
#include <QPointer>
#include <QStringList>
#include <QTimer>
#include <qtmetamacros.h>
class CardDatabaseDisplayModel;
class CardDatabaseQuerier;
class CardDatabaseVisualDisplayModel : public QAbstractListModel
{
Q_OBJECT
public:
/**
* @brief Flattens _displayModel into the cards a visual grid displays.
* @param _displayModel The filtered and sorted proxy model of the card database.
* @param _querier Resolves the preferred printing of a card.
* @param parent Parent object.
*/
explicit CardDatabaseVisualDisplayModel(CardDatabaseDisplayModel *_displayModel,
CardDatabaseQuerier *_querier,
QObject *parent = nullptr);
/**
* @brief Sets the set shorthand names of the active set filters.
*
* With no terms every card is shown with its preferred printing. With terms
* every printing belonging to one of the named sets is shown instead, in term
* order. Rebuilds immediately when the terms change.
*/
void setSetFilterTerms(const QStringList &terms);
/**
* @brief Rebuilds the row list from the proxy model right away.
*
* Proxy model changes normally rebuild on a short coalescing timer; call this
* when the caller needs the new rows before returning.
*/
void refresh();
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
[[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
/**
* @brief Returns the card painted at @p row, with its preferred printing resolved.
* @param row Row in this model, not in the proxy model.
*/
[[nodiscard]] ExactCard cardAt(int row) const;
private:
struct Entry
{
CardInfoPtr info;
/// Empty when the entry follows the preferred printing rather than a set filter.
PrintingInfo printing;
};
void scheduleRebuild();
void rebuild();
void appendPrintingsFor(const CardInfoPtr &info);
QPointer<CardDatabaseDisplayModel> displayModel;
CardDatabaseQuerier *querier;
QStringList setFilterTerms;
QList<Entry> entries;
QTimer rebuildTimer;
};
#endif // COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H

View file

@ -35,6 +35,21 @@ if(WITH_ORACLE OR WITH_CLIENT)
LIBS LIBS
libcockatrice_filters libcockatrice_filters
) )
# ------------------------
# Visual Display Model Test
# ------------------------
cockatrice_add_test(
NAME
visual_display_model_test
SOURCES
${VERSION_STRING_CPP}
visual_display_model_test.cpp
mocks.cpp
LIBS
libcockatrice_card
libcockatrice_models
)
endif() endif()
# ------------------------ # ------------------------

View file

@ -0,0 +1,188 @@
/**
* @file visual_display_model_test.cpp
* @brief Tests for the flattened model behind the visual card database grid.
*/
#include "card_database_display_model.h"
#include "card_database_model.h"
#include "card_database_visual_display_model.h"
#include "libcockatrice/card/card_info.h"
#include "libcockatrice/card/database/card_database.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/card/printing/printing_info.h"
#include "libcockatrice/card/set/card_set.h"
#include "libcockatrice/interfaces/noop_card_preference_provider.h"
#include "libcockatrice/interfaces/noop_card_set_priority_controller.h"
#include "test_card_database_path_provider.h"
#include "gtest/gtest.h"
#include <QCoreApplication>
#include <QDate>
#include <QEventLoop>
#include <QHash>
#include <QMap>
#include <QString>
#include <QTimer>
#include <memory>
namespace
{
// GTest's main() creates no QCoreApplication, which QTimer needs for its event loop.
const QCoreApplication &application()
{
static int argc = 1;
static char executable[] = "visual_display_model_test";
static char *argv[] = {executable, nullptr};
static const QCoreApplication app(argc, argv);
return app;
}
/// Lets the rebuild timer fire, as it would in the running client.
void waitForRebuild()
{
QEventLoop loop;
QTimer::singleShot(100, &loop, &QEventLoop::quit);
loop.exec();
}
class CardDatabaseVisualDisplayModelTest : public ::testing::Test
{
protected:
void SetUp() override
{
(void)application();
db = std::make_unique<CardDatabase>(nullptr, new NoopCardPreferenceProvider(),
new TestCardDatabasePathProvider(), new NoopCardSetPriorityController());
db->loadCardDatabases();
cardModel = std::make_unique<CardDatabaseModel>(db.get(), false);
displayModel = std::make_unique<CardDatabaseDisplayModel>();
displayModel->setSourceModel(cardModel.get());
visualModel = std::make_unique<CardDatabaseVisualDisplayModel>(displayModel.get(), db->query());
visualModel->refresh();
}
/**
* @brief Builds a card with @p printingCount printings in each named set.
* @param printingCountPerSet Set short name to the number of printings in that set.
*/
[[nodiscard]] CardInfoPtr makeCard(const QString &name, const QMap<QString, int> &printingCountPerSet)
{
SetToPrintingsMap setsInfo;
for (auto it = printingCountPerSet.constBegin(); it != printingCountPerSet.constEnd(); ++it) {
CardSetPtr set = CardSet::newInstance(&setController, it.key(), it.key() + " test set", "expansion",
QDate(), CardSet::PriorityPrimary);
for (int printing = 0; printing < it.value(); ++printing) {
QHash<QString, QString> properties;
properties["uuid"] = QString("%1-%2-%3").arg(it.key(), name).arg(printing);
setsInfo[it.key()].append(PrintingInfo(set, LazyPropertiesHash(properties)));
}
}
CardInfo::UiAttributes attributes = {.tableRow = 1};
return CardInfo::newInstance(name, "", false, {}, {}, {}, setsInfo, attributes);
}
NoopCardSetPriorityController setController;
std::unique_ptr<CardDatabase> db;
std::unique_ptr<CardDatabaseModel> cardModel;
std::unique_ptr<CardDatabaseDisplayModel> displayModel;
std::unique_ptr<CardDatabaseVisualDisplayModel> visualModel;
};
TEST_F(CardDatabaseVisualDisplayModelTest, ExposesTheCardInfoBehindAProxyRow)
{
ASSERT_GT(displayModel->rowCount(), 0);
const QVariant info = displayModel->data(displayModel->index(0, CardDatabaseModel::NameColumn),
CardDatabaseDisplayModel::CardInfoRole);
ASSERT_TRUE(info.isValid());
EXPECT_FALSE(info.value<CardInfoPtr>().isNull());
}
TEST_F(CardDatabaseVisualDisplayModelTest, FlattensOneRowPerProxyCardWithoutSetFilters)
{
ASSERT_GT(displayModel->rowCount(), 0);
EXPECT_EQ(displayModel->rowCount(), visualModel->rowCount());
for (int row = 0; row < visualModel->rowCount(); ++row) {
const ExactCard card = visualModel->cardAt(row);
ASSERT_TRUE(card) << "row " << row;
EXPECT_EQ(
displayModel->data(displayModel->index(row, CardDatabaseModel::NameColumn), Qt::DisplayRole).toString(),
card.getName())
<< "row " << row;
}
}
TEST_F(CardDatabaseVisualDisplayModelTest, ShowsOneRowPerCardWhenNoSetIsFiltered)
{
const int rowsBefore = visualModel->rowCount();
db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}}));
visualModel->refresh();
EXPECT_EQ(rowsBefore + 1, visualModel->rowCount());
EXPECT_EQ("Multi", visualModel->cardAt(visualModel->rowCount() - 1).getName());
}
TEST_F(CardDatabaseVisualDisplayModelTest, ShowsEveryPrintingOfAFilteredSet)
{
db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}}));
visualModel->refresh();
visualModel->setSetFilterTerms({"TST"});
ASSERT_EQ(2, visualModel->rowCount());
for (int row = 0; row < visualModel->rowCount(); ++row) {
const ExactCard card = visualModel->cardAt(row);
ASSERT_TRUE(card) << "row " << row;
EXPECT_EQ("Multi", card.getName());
ASSERT_FALSE(card.getPrinting().isEmpty()) << "row " << row;
EXPECT_EQ("TST", card.getPrinting().getSet()->getShortName()) << "row " << row;
}
EXPECT_NE(visualModel->cardAt(0).getPrinting(), visualModel->cardAt(1).getPrinting());
}
TEST_F(CardDatabaseVisualDisplayModelTest, HidesCardsWithoutAPrintingInTheFilteredSets)
{
visualModel->setSetFilterTerms({"NO SUCH SET"});
EXPECT_EQ(0, visualModel->rowCount());
}
TEST_F(CardDatabaseVisualDisplayModelTest, RestoresOneRowPerCardWhenTheSetTermsClear)
{
db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}}));
visualModel->refresh();
const int rowsBefore = visualModel->rowCount();
visualModel->setSetFilterTerms({"TST"});
ASSERT_EQ(2, visualModel->rowCount());
visualModel->setSetFilterTerms({});
EXPECT_EQ(rowsBefore, visualModel->rowCount());
}
TEST_F(CardDatabaseVisualDisplayModelTest, RebuildsByItselfWhenTheProxyGainsARow)
{
const int rowsBefore = visualModel->rowCount();
db->addCard(makeCard("Late", {{"TST", 1}}));
waitForRebuild();
EXPECT_EQ(rowsBefore + 1, visualModel->rowCount());
}
TEST_F(CardDatabaseVisualDisplayModelTest, ReportsTheCardNameForDisplayAndAccessibleText)
{
ASSERT_GT(visualModel->rowCount(), 0);
const QString expected = visualModel->cardAt(0).getName();
EXPECT_EQ(expected, visualModel->data(visualModel->index(0), Qt::DisplayRole).toString());
EXPECT_EQ(expected, visualModel->data(visualModel->index(0), Qt::AccessibleTextRole).toString());
EXPECT_FALSE(visualModel->data(QModelIndex(), Qt::DisplayRole).isValid());
}
} // namespace

2
vcpkg

@ -1 +1 @@
Subproject commit 9eec002df1cd109f8f104cd966a763daf9751489 Subproject commit 9e593bb18ea69cc5095e012465dcd675a822ed0d