Cockatrice/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp
BruebachL a1a8a59f4a
[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>
2026-11-02 08:35:58 +01:00

524 lines
17 KiB
C++

#include "card_info_picture_widget.h"
#include "../../../client/settings/cache_settings.h"
#include "../../../interface/card_picture_loader/card_picture_loader.h"
#include "../../../interface/widgets/tabs/tab_supervisor.h"
#include "../../window_main.h"
#include "../tabs/abstract_tab_deck_editor.h"
#include "card_art_utils.h"
#include "card_info_picture_enlarged_widget.h"
#include "libcockatrice/card/card_info.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/deck_list/tree/inner_deck_list_node.h"
#include <QAbstractAnimation>
#include <QAction>
#include <QApplication>
#include <QByteArray>
#include <QCursor>
#include <QDebug>
#include <QEasingCurve>
#include <QGuiApplication>
#include <QList>
#include <QMenu>
#include <QMouseEvent>
#include <QObject>
#include <QPainterPath>
#include <QPropertyAnimation>
#include <QRect>
#include <QScreen>
#include <QSharedPointer>
#include <QSize>
#include <QString>
#include <QStylePainter>
#include <QTimer>
#include <QWidget>
#include <QtGlobal>
#include <algorithm>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/card/relation/card_relation.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <qlogging.h>
#include <qnamespace.h>
static constexpr int BASE_HEIGHT = 200;
static constexpr int ENLARGED_PIXMAP_OFFSET = 10;
static constexpr int HOVER_ACTIVATE_THRESHOLD_MS = 500;
static constexpr int ANIMATION_OFFSET = 10; // Adjust this for how much the widget moves up
/**
* @class CardInfoPictureWidget
* @brief Widget that displays an enlarged image of a card, loading the image based on the card's info or showing a
* default image.
*
* This widget can optionally display a larger version of the card's image when hovered over,
* depending on the `hoverToZoomEnabled` parameter.
*/
/**
* @brief Constructs a CardInfoPictureWidget.
* @param parent The parent widget, if any.
* @param hoverToZoomEnabled If this widget will spawn a larger widget when hovered over.
*
* Initializes the widget with a minimum height and sets the pixmap to a dirty state for initial loading.
*/
CardInfoPictureWidget::CardInfoPictureWidget(QWidget *parent, const bool _hoverToZoomEnabled, const bool _raiseOnEnter)
: QWidget(parent), pixmapDirty(true), hoverToZoomEnabled(_hoverToZoomEnabled), raiseOnEnter(_raiseOnEnter)
{
setMinimumHeight(BASE_HEIGHT);
if (hoverToZoomEnabled) {
setMouseTracking(true);
}
hoverTimer = new QTimer(this);
hoverTimer->setSingleShot(true);
connect(hoverTimer, &QTimer::timeout, this, &CardInfoPictureWidget::showEnlargedPixmap);
// Store the widget's original position
originalPos = this->pos();
// Create the animation
animation = new QPropertyAnimation(this, "pos", this);
animation->setDuration(200); // 200ms animation duration
animation->setEasingCurve(QEasingCurve::OutQuad);
animation->setStartValue(originalPos);
animation->setEndValue(originalPos - QPoint(0, ANIMATION_OFFSET));
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this,
[this](bool _roundCardCorners) {
Q_UNUSED(_roundCardCorners);
update();
});
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
&CardInfoPictureWidget::updatePixmap);
}
/**
* @brief Sets the card to be displayed and updates the pixmap.
* @param card A shared pointer to the card information (CardInfoPtr).
*
* Disconnects any existing signal connections from the previous card info and connects to the `pixmapUpdated`
* signal of the new card to automatically update the pixmap when the card image changes.
*/
void CardInfoPictureWidget::setCard(const ExactCard &card)
{
if (exactCard.getCardPtr()) {
disconnect(exactCard.getCardPtr().data(), nullptr, this, nullptr);
}
exactCard = card;
if (exactCard.getCardPtr()) {
connect(exactCard.getCardPtr().data(), &CardInfo::pixmapUpdated, this, &CardInfoPictureWidget::updatePixmap);
}
updatePixmap();
}
/**
* @brief Sets the hover to zoom feature.
* @param enabled If true, enables the hover-to-zoom functionality; otherwise, disables it.
*/
void CardInfoPictureWidget::setHoverToZoomEnabled(const bool enabled)
{
hoverToZoomEnabled = enabled;
setMouseTracking(enabled);
}
void CardInfoPictureWidget::setRaiseOnEnterEnabled(const bool enabled)
{
raiseOnEnter = enabled;
}
/**
* @brief Handles widget resizing by updating the pixmap size.
* @param event The resize event (unused).
*
* Calls `updatePixmap()` to ensure the image scales appropriately when the widget is resized.
*/
void CardInfoPictureWidget::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
originalPos = pos(); // Update the baseline position
updatePixmap();
}
/**
* @brief Sets the scale factor for the widget.
* @param scale The scale factor to apply.
*
* Adjusts the widget's size according to the scale factor and updates the pixmap.
*/
void CardInfoPictureWidget::setScaleFactor(const int scale)
{
const QSize newSize = sizeForScaleFactor(scale);
scaleFactor = scale;
setFixedSize(newSize);
updatePixmap();
emit cardScaleFactorChanged(scale);
}
/**
* @brief Marks the pixmap as dirty and triggers a widget repaint.
*
* Sets `pixmapDirty` to true, indicating that the pixmap needs to be reloaded before the next display.
*/
void CardInfoPictureWidget::updatePixmap()
{
pixmapDirty = true;
update();
}
/**
* @brief Loads the appropriate pixmap based on the current card info.
*
* If `info` is valid, loads the card's image. Otherwise, loads a default card back image.
*/
void CardInfoPictureWidget::loadPixmap()
{
CardPictureLoader::getCardBackLoadingInProgressPixmap(resizedPixmap, size());
if (exactCard) {
CardPictureLoader::getPixmap(resizedPixmap, exactCard, size());
} else {
CardPictureLoader::getCardBackLoadingFailedPixmap(resizedPixmap, size());
}
pixmapDirty = false;
}
/**
* @brief Custom paint event that draws the card image with rounded corners.
* @param event The paint event (unused).
*
* Checks if the pixmap needs to be reloaded. Then, calculates the size and position for centering the
* scaled pixmap within the widget, applies rounded corners, and draws the pixmap.
*/
void CardInfoPictureWidget::paintEvent(QPaintEvent *event)
{
QWidget::paintEvent(event);
if (width() == 0 || height() == 0) {
return;
}
if (pixmapDirty) {
loadPixmap();
}
QPixmap transformedPixmap = resizedPixmap; // Default pixmap
if (SettingsCache::instance().cardsDisplay().getAutoRotateSidewaysLayoutCards()) {
transformedPixmap = CardArtUtils::rotateSidewaysLayoutArt(resizedPixmap, exactCard);
}
// Handle DPI scaling
qreal dpr = devicePixelRatio(); // Get the actual scaling factor
QSize availableSize = size() * dpr; // Convert to physical pixel size
// Compute final scaled size
QSize pixmapSize = transformedPixmap.size();
QSize scaledSize = pixmapSize.scaled(availableSize, Qt::KeepAspectRatio);
// Pre-scale the pixmap once before drawing
QPixmap finalPixmap = transformedPixmap.scaled(scaledSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
finalPixmap.setDevicePixelRatio(dpr); // Ensure correct display on high-DPI screens
// Compute target rectangle with explicit integer conversion
int targetX = static_cast<int>((availableSize.width() - scaledSize.width()) / (2 * dpr));
int targetY = static_cast<int>((availableSize.height() - scaledSize.height()) / (2 * dpr));
int targetW = static_cast<int>(scaledSize.width() / dpr);
int targetH = static_cast<int>(scaledSize.height() / dpr);
QRect targetRect{targetX, targetY, targetW, targetH};
// Compute rounded corner radius
// Ensure consistent rounding
qreal radius = SettingsCache::instance().cardsDisplay().getRoundCardCorners()
? 0.05 * static_cast<qreal>(targetRect.width())
: 0.;
// Draw the pixmap with rounded corners
QStylePainter painter(this);
QPainterPath shape;
shape.addRoundedRect(targetRect, radius, radius);
painter.setClipPath(shape);
// Draw the pre-scaled pixmap directly
painter.drawPixmap(targetRect, finalPixmap);
}
/**
* @brief Provides the recommended size for the widget based on the scale factor.
* @return The recommended widget size.
*/
QSize CardInfoPictureWidget::sizeHint() const
{
return sizeForScaleFactor(scaleFactor);
}
QSize CardInfoPictureWidget::sizeForScaleFactor(int scaleFactor)
{
const int width = BASE_WIDTH * scaleFactor / 100;
return {width, static_cast<int>(width * ASPECT_RATIO)};
}
/**
* @brief Starts the hover timer to show the enlarged pixmap on hover.
* @param event The enter event.
*/
void CardInfoPictureWidget::enterEvent(QEnterEvent *event)
{
QWidget::enterEvent(event); // Call the base class implementation
// If hover-to-zoom is enabled, start the hover timer
if (hoverToZoomEnabled) {
hoverTimer->start(HOVER_ACTIVATE_THRESHOLD_MS);
}
// Emit signal indicating a card is being hovered on
emit hoveredOnCard(exactCard);
if (raiseOnEnter) {
if (animation->state() == QAbstractAnimation::Running) {
animation->pause(); // Pause current animation
} else {
originalPos = this->pos(); // Update the baseline position
animation->setStartValue(originalPos);
animation->setEndValue(originalPos - QPoint(0, ANIMATION_OFFSET));
}
animation->setDirection(QAbstractAnimation::Forward);
animation->start();
}
}
/**
* @brief Stops the hover timer and hides the enlarged pixmap when the mouse leaves.
* @param event The leave event.
*/
void CardInfoPictureWidget::leaveEvent(QEvent *event)
{
QWidget::leaveEvent(event);
if (hoverToZoomEnabled) {
hoverTimer->stop();
destroyEnlargedPixmapWidget();
}
if (raiseOnEnter) {
if (animation->state() == QAbstractAnimation::Running) {
animation->pause(); // Pause current animation
}
animation->setDirection(QAbstractAnimation::Backward);
animation->start();
}
}
void CardInfoPictureWidget::moveEvent(QMoveEvent *event)
{
QWidget::moveEvent(event);
hoverTimer->stop();
destroyEnlargedPixmapWidget();
if (animation->state() == QAbstractAnimation::Running) {
return;
}
originalPos = this->pos(); // Update the baseline position
}
/**
* @brief Moves the enlarged pixmap widget to follow the mouse cursor.
* @param event The mouse move event.
*/
void CardInfoPictureWidget::mouseMoveEvent(QMouseEvent *event)
{
QWidget::mouseMoveEvent(event);
if (hoverToZoomEnabled && enlargedPixmapWidget && enlargedPixmapWidget->isVisible()) {
const QPoint cursorPos = QCursor::pos();
const QRect screenGeometry = QGuiApplication::screenAt(cursorPos)->geometry();
const QSize widgetSize = enlargedPixmapWidget->size();
int newX = cursorPos.x() + ENLARGED_PIXMAP_OFFSET;
int newY = cursorPos.y() + ENLARGED_PIXMAP_OFFSET;
// Adjust if out of bounds
if (newX + widgetSize.width() > screenGeometry.right()) {
newX = cursorPos.x() - widgetSize.width() - ENLARGED_PIXMAP_OFFSET;
}
if (newY + widgetSize.height() > screenGeometry.bottom()) {
newY = cursorPos.y() - widgetSize.height() - ENLARGED_PIXMAP_OFFSET;
}
enlargedPixmapWidget->move(newX, newY);
}
}
void CardInfoPictureWidget::mousePressEvent(QMouseEvent *event)
{
QWidget::mousePressEvent(event);
if (event->button() == Qt::RightButton) {
createRightClickMenu()->popup(QCursor::pos());
}
emit cardClicked(event, exactCard);
}
void CardInfoPictureWidget::hideEvent(QHideEvent *event)
{
destroyEnlargedPixmapWidget();
QWidget::hideEvent(event);
}
QMenu *CardInfoPictureWidget::createRightClickMenu()
{
auto *cardMenu = new QMenu(this);
if (!exactCard) {
return cardMenu;
}
cardMenu->addMenu(createViewRelatedCardsMenu());
cardMenu->addMenu(createAddToOpenDeckMenu());
return cardMenu;
}
QMenu *CardInfoPictureWidget::createViewRelatedCardsMenu()
{
auto viewRelatedCards = new QMenu(tr("View related cards"));
QList<CardRelation *> relatedCards = exactCard.getInfo().getAllRelatedCards();
auto relatedCardExists = [](const CardRelation *cardRelation) {
return CardDatabaseManager::query()->getCardInfo(cardRelation->getName()) != nullptr;
};
bool atLeastOneGoodRelationFound = std::any_of(relatedCards.begin(), relatedCards.end(), relatedCardExists);
if (!atLeastOneGoodRelationFound) {
viewRelatedCards->setEnabled(false);
return viewRelatedCards;
}
for (const auto &relatedCard : relatedCards) {
const auto &relatedCardName = relatedCard->getName();
QAction *viewCard = viewRelatedCards->addAction(relatedCardName);
connect(viewCard, &QAction::triggered, this, [this, &relatedCardName] {
emit cardChanged(
CardDatabaseManager::query()->getCard({relatedCardName, exactCard.getPrinting().getUuid()}));
});
viewRelatedCards->addAction(viewCard);
}
return viewRelatedCards;
}
/**
* Finds the single instance of the MainWindow in this application.
*/
static MainWindow *findMainWindow()
{
for (auto widget : QApplication::topLevelWidgets()) {
if (auto mainWindow = qobject_cast<MainWindow *>(widget)) {
return mainWindow;
}
}
// This code should be unreachable
qCritical() << "Could not find MainWindow in QApplication::topLevelWidgets";
return nullptr;
}
QMenu *CardInfoPictureWidget::createAddToOpenDeckMenu()
{
auto addToOpenDeckMenu = new QMenu(tr("Add card to deck"));
auto mainWindow = findMainWindow();
QList<AbstractTabDeckEditor *> deckEditorTabs = mainWindow->getTabSupervisor()->getDeckEditorTabs();
if (deckEditorTabs.isEmpty()) {
addToOpenDeckMenu->setEnabled(false);
return addToOpenDeckMenu;
}
for (auto &deckEditorTab : deckEditorTabs) {
auto *addCardMenu = addToOpenDeckMenu->addMenu(deckEditorTab->getTabText());
QAction *addCard = addCardMenu->addAction(tr("Mainboard"));
connect(addCard, &QAction::triggered, this, [this, deckEditorTab] {
deckEditorTab->updateCard(exactCard);
deckEditorTab->addCard(exactCard, DECK_ZONE_MAIN);
});
QAction *addCardSideboard = addCardMenu->addAction(tr("Sideboard"));
connect(addCardSideboard, &QAction::triggered, this, [this, deckEditorTab] {
deckEditorTab->updateCard(exactCard);
deckEditorTab->addCard(exactCard, DECK_ZONE_SIDE);
});
}
return addToOpenDeckMenu;
}
/**
* @brief Displays the enlarged version of the card's pixmap near the cursor.
*
* If card information is available, the enlarged pixmap is loaded, positioned near the cursor,
* and displayed.
*/
void CardInfoPictureWidget::showEnlargedPixmap()
{
if (!exactCard) {
return;
}
// Lazy creation of the enlarged widget
if (!enlargedPixmapWidget) {
enlargedPixmapWidget = new CardInfoPictureEnlargedWidget(const_cast<CardInfoPictureWidget *>(this)->window());
enlargedPixmapWidget->hide();
connect(this, &QObject::destroyed, enlargedPixmapWidget, &CardInfoPictureEnlargedWidget::deleteLater);
}
const QSize enlargedSize(static_cast<int>(size().width() * 2), static_cast<int>(size().width() * ASPECT_RATIO * 2));
enlargedPixmapWidget->setCardPixmap(exactCard, enlargedSize);
const QPoint cursorPos = QCursor::pos();
QScreen *screen = QGuiApplication::screenAt(cursorPos);
if (!screen) {
screen = QGuiApplication::primaryScreen();
}
if (!screen) {
return;
}
const QRect screenGeometry = screen->geometry();
const QSize widgetSize = enlargedPixmapWidget->size();
int newX = cursorPos.x() + ENLARGED_PIXMAP_OFFSET;
int newY = cursorPos.y() + ENLARGED_PIXMAP_OFFSET;
if (newX + widgetSize.width() > screenGeometry.right()) {
newX = cursorPos.x() - widgetSize.width() - ENLARGED_PIXMAP_OFFSET;
}
if (newY + widgetSize.height() > screenGeometry.bottom()) {
newY = cursorPos.y() - widgetSize.height() - ENLARGED_PIXMAP_OFFSET;
}
enlargedPixmapWidget->move(newX, newY);
enlargedPixmapWidget->show();
}
void CardInfoPictureWidget::destroyEnlargedPixmapWidget()
{
if (enlargedPixmapWidget) {
enlargedPixmapWidget->deleteLater();
enlargedPixmapWidget = nullptr;
}
}