Cockatrice/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp
BruebachL 1c93309952
[Client] Show localized card names, texts and pictures (#7294)
* [Client] Show localized card names, texts and pictures

Localization wiring now runs end to end: the oracle importer collects
foreignData for the configured language and the client renders it.

- [Oracle] Import localized names and rules texts for the selected cardLang
  - single-face cards store their foreignData name and full text
  - multi-face (split/adventure/aftermath/prepare) cards collect the joined
    name once and join each face's translated text with the same separator
    as the English merge; an incomplete translation falls back to English;
    the joined text follows the same highest-priority-set policy as the
    single-face path and is only collected when localization is enabled
  - the wizard switching languages re-imports the card database

- [Client] Display localized card info throughout the client
  - card info text/picture widgets and the game board re-render on language
    change
  - pictures resolve cardLang art through Scryfall's named endpoint using the
    localized name, falling back to id-based art when no match exists
  - deck editor keeps canonical English names as card identity (EditRole)
    while showing localized names (DisplayRole), so decks and wire names
    stay stable

- [Card] Add CardLocalization-backed name/text lookup and cards.xml v4
  localization elements with a bounded-size translation cache

- [Tests] Cover oracle foreignData import (incl. multi-face joins, priority
  and fallback paths), XML v4 localization parsing, deck model localized
  display and the language-aware settings default

Existing installations need to re-run Oracle to see translations: localized
data only lands in cards.xml when the Oracle app is started with the
preferred language selected — launch the separate "Oracle" program that
ships with Cockatrice, pick the language in the wizard and let it re-import
the card database.

The client's database cache (cards.xml.cache) is invalidated by the cache
format bump and the source-hash checks, but a cache written before the
re-import can still hold English-only entries (the hash uses file size and
mtime, so a same-size/same-timestamp rewrite may be served as-is); delete
cards.xml.cache and relaunch if no localized names/texts show up after
re-importing.

* [Card] Pass localized card names and texts into CardInfo construction

Address review: instead of constructing the card and then calling
setLocalizedName/setLocalizedText (which emit a cardInfoChanged signal per
language), both constructors, both newInstance overloads and their callers
(cards.xml v4 parser and the binary cache reader) now pass the localized maps
as constructor arguments.

* [Client] Rename LocalizedCard:: helpers namespace to CardLocalization

The namespace now matches its header file name, as the review pointed out;
LocalizedCard reads more like a class or struct. Callers (card info text
widget, board card name rendering) are updated to match.

* [Client] Drop unused info member from the card info text widget

The CardInfoPtr member was only ever initialized to nullptr and never read;
remove it together with its initializer.

* [PictureLoader] Add the localized picture URL explicitly, not implicitly

Address review: silently prepending the Scryfall named-picture URL to the
download list whenever a non-English card language was active was surprising,
consumed quota per card when it failed, and could grab the wrong (canon) art on
name collisions, with no way to turn it off.

The insert is now opt-in and user-controlled: changing the card language adds
the template to the top of the download URLs once (persisted, documented in the
re-import prompt, and editable/removable in the deck editor settings), while the
picture loader no longer injects it at request time.

* [Card] Show card languages in the same native (English) format as the UI

Address review: the card text & images language dropdown listed bare native
names, some in inconsistent lowercase (e.g. "čeština", "español de España"),
which makes the languages easy to mix up for users that do not read the script
(e.g. 日本語 vs 한국어). It now mirrors the UI language dropdown and always pairs
the native name with its English name (e.g. "Deutsch (German)",
"日本語 (Japanese)"), using the same fixed casing.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-18 12:03:07 +02:00

492 lines
16 KiB
C++

#include "card_info_picture_widget.h"
#include "../../../client/settings/cache_settings.h"
#include "../../../game_graphics/board/card_item.h"
#include "../../../interface/card_picture_loader/card_picture_loader.h"
#include "../../../interface/widgets/tabs/tab_supervisor.h"
#include "../../window_main.h"
#include "card_art_utils.h"
#include <QMenu>
#include <QMouseEvent>
#include <QScreen>
#include <QStylePainter>
#include <QWidget>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/card/relation/card_relation.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <utility>
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 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 int newWidth = BASE_WIDTH * scale / 100;
const int newHeight = static_cast<int>(newWidth * ASPECT_RATIO);
scaleFactor = scale;
setFixedSize(newWidth, newHeight);
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 {static_cast<int>(BASE_WIDTH * scaleFactor / 100.0),
static_cast<int>(BASE_WIDTH * scaleFactor / 100.0 * 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();
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;
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;
}
}