From 0d0488cb5b6df8ff73a0c2a4576d82ed64235760 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:01:59 +0200 Subject: [PATCH 1/6] [Client] Edit banner crop by direct manipulation in the card art dialog (#7162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offset and zoom fields exposed raw numbers with no relation to the strip, and the stored printing reset to the first local printing on every open The preview now paints through UserListPainter::drawCardArt itself, so what you see is exactly what the user list renders. Drag pans the art vertically at output scale, wheel zooms, arrow keys nudge, plus and minus zoom, Backspace or Esc restores the crop as of focus gain and lets Esc close the dialog when unchanged. Margins stay explicit spinboxes since they trim the strip sides with no natural drag mapping. Legacy stored zoom below the gesture floor is normalized once on open, focus ring and accessible name and description cover keyboard and screen reader users, all strings set in retranslateUi Took 7 minutes # Commit time for manual adjustment: # Took 55 seconds Took 41 seconds Co-authored-by: Lukas Brübach --- .../server/user/user_card_settings_dialog.cpp | 295 ++++++++++++++++-- .../server/user/user_card_settings_dialog.h | 47 ++- 2 files changed, 305 insertions(+), 37 deletions(-) diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp index 1d76b2c67..532112964 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp @@ -15,19 +15,46 @@ #include #include #include +#include #include #include +#include #include #include #include #include #include +#include +#include #include +namespace +{ +// Gesture clamps and step sizes for this direct manipulation surface. The +// gesture zoom floor is 1.0: basescale already cover fits the art, so any +// smaller scale would underfill the strip. +constexpr qreal kMinGestureZoom = 1.0; +constexpr qreal kMaxZoom = 4.0; +constexpr qreal kKeyPanOffsetStep = 0.01; +constexpr qreal kKeyZoomStep = 1.05; +constexpr qreal kWheelZoomBase = 1.15; // zoom factor per wheel notch +} // namespace + CardArtPreviewWidget::CardArtPreviewWidget(QWidget *parent) : QWidget(parent) { setMinimumSize(400, 72); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + setFocusPolicy(Qt::StrongFocus); + setAccessibleName(tr("Banner preview")); + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); +} + +void CardArtPreviewWidget::focusInEvent(QFocusEvent *event) +{ + // Snapshot for the Esc or Backspace reset, restoring whatever the user + // had when the surface took focus + paramsAtFocusIn = params; + QWidget::focusInEvent(event); } void CardArtPreviewWidget::setPixmap(const QPixmap &pixmap) @@ -39,6 +66,7 @@ void CardArtPreviewWidget::setPixmap(const QPixmap &pixmap) void CardArtPreviewWidget::setParams(const CardArtParams &p) { params = p; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); update(); } @@ -67,9 +95,22 @@ void CardArtPreviewWidget::paintEvent(QPaintEvent *) painter.setBrush(accentColor); painter.drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2); + // Visible keyboard focus per the focus cursor contract, Tab must show + // where the keys land, including in the empty state + const auto paintFocusRing = [&painter, &cardRect, this]() { + if (!hasFocus()) { + return; + } + QPen focusPen(palette().color(QPalette::Highlight), 2); + painter.setPen(focusPen); + painter.setBrush(Qt::NoBrush); + painter.drawRoundedRect(cardRect.adjusted(-1, -1, 1, 1), 6, 6); + }; + if (sourcePixmap.isNull()) { painter.setPen(QColor(150, 150, 150)); painter.drawText(rect, Qt::AlignCenter, tr("No card selected")); + paintFocusRing(); return; } @@ -80,7 +121,7 @@ void CardArtPreviewWidget::paintEvent(QPaintEvent *) &sourcePixmap // direct pixmap ); - // Avatar placeholder so the left-margin interaction is visible + // Avatar placeholder so the left margin interaction is visible const int avatarX = rect.left() + 14; const int avatarY = rect.top() + (rect.height() - 36) / 2; const QRect avatarRect(avatarX, avatarY, 36, 36); @@ -99,37 +140,200 @@ void CardArtPreviewWidget::paintEvent(QPaintEvent *) painter.drawEllipse(avatarRect.adjusted(-1, -1, 1, 1)); paintArtAttribution(painter, cardRect, attributionText); + + paintFocusRing(); +} + +qreal CardArtPreviewWidget::bannerTravel() const +{ + if (sourcePixmap.isNull()) { + return 0.0; + } + // Mirror UserListPainter::drawCardArt() exactly: same strip metrics, the + // copy is drawn 1:1, so output pixels equal widget pixels here. + const int cardH = rect().height() - 4; + const int totalW = (rect().right() - 4) - rect().left(); + const int marginL = qRound(totalW * params.marginPctL); + const int marginR = qRound(totalW * params.marginPctR); + const int drawW = totalW - marginL - marginR; + const double basescale = qMax(double(drawW) / sourcePixmap.width(), double(cardH) / sourcePixmap.height()); + // qRound for literal parity with drawCardArt, which rounds the scaled + // height before computing travel + const double scaledH = qRound(sourcePixmap.height() * basescale * params.zoom); + return scaledH - cardH; +} + +void CardArtPreviewWidget::applyCropDelta(qreal dOffset, qreal zoomFactor) +{ + CardArtParams next = params; + next.verticalOffset = qBound(0.0, params.verticalOffset + dOffset, 1.0); + next.zoom = qBound(kMinGestureZoom, params.zoom * zoomFactor, kMaxZoom); + + if (sameCrop(next, params)) { + return; + } + + params = next; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); + update(); + emit paramsEdited(params); +} + +bool CardArtPreviewWidget::sameCrop(const CardArtParams &a, const CardArtParams &b) const +{ + // Exact comparison on purpose: clamped assignments yield identical bits, + // while qFuzzyCompare based equality misbehaves around zero + return a.verticalOffset == b.verticalOffset && a.zoom == b.zoom; +} + +void CardArtPreviewWidget::restoreSnapshot() +{ + // The snapshot only ever holds values that passed the gesture clamps, + // so it is safe to restore verbatim + if (sameCrop(paramsAtFocusIn, params)) { + return; + } + params = paramsAtFocusIn; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); + update(); + emit paramsEdited(params); +} + +void CardArtPreviewWidget::mousePressEvent(QMouseEvent *event) +{ + if (event->button() != Qt::LeftButton || sourcePixmap.isNull() || bannerTravel() <= 0.5) { + QWidget::mousePressEvent(event); + return; + } + dragging = true; + lastDragPos = event->pos(); + setCursor(Qt::ClosedHandCursor); + event->accept(); +} + +void CardArtPreviewWidget::mouseMoveEvent(QMouseEvent *event) +{ + if (!dragging || sourcePixmap.isNull()) { + QWidget::mouseMoveEvent(event); + return; + } + + const qreal dy = event->pos().y() - lastDragPos.y(); + lastDragPos = event->pos(); + + const qreal travel = bannerTravel(); + if (travel <= 0.5) { + event->accept(); + return; + } + + // Dragging moves the ART with the cursor, so the crop window slides the + // other way through the available travel. + applyCropDelta(-dy / travel, 1.0); + event->accept(); +} + +void CardArtPreviewWidget::mouseReleaseEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) { + dragging = false; + unsetCursor(); + event->accept(); + return; + } + QWidget::mouseReleaseEvent(event); +} + +void CardArtPreviewWidget::wheelEvent(QWheelEvent *event) +{ + if (sourcePixmap.isNull()) { + QWidget::wheelEvent(event); + return; + } + const qreal notches = static_cast(event->angleDelta().y()) / 120.0; + if (notches == 0.0) { + event->accept(); + return; + } + applyCropDelta(0.0, std::pow(kWheelZoomBase, notches)); + event->accept(); +} + +void CardArtPreviewWidget::keyPressEvent(QKeyEvent *event) +{ + if (sourcePixmap.isNull()) { + QWidget::keyPressEvent(event); + return; + } + + switch (event->key()) { + case Qt::Key_Escape: + if (sameCrop(params, paramsAtFocusIn)) { + // Nothing to undo on this surface, let the event reach the + // dialog so Esc keeps its close meaning there + QWidget::keyPressEvent(event); + return; + } + restoreSnapshot(); + break; + case Qt::Key_Backspace: + restoreSnapshot(); + break; + case Qt::Key_Up: + applyCropDelta(-kKeyPanOffsetStep, 1.0); + break; + case Qt::Key_Down: + applyCropDelta(kKeyPanOffsetStep, 1.0); + break; + case Qt::Key_Plus: + case Qt::Key_Equal: + applyCropDelta(0.0, kKeyZoomStep); + break; + case Qt::Key_Minus: + applyCropDelta(0.0, 1.0 / kKeyZoomStep); + break; + default: + QWidget::keyPressEvent(event); + return; + } + event->accept(); } UserCardArtSettingsDialog::UserCardArtSettingsDialog(const CardArtParams &initial, QWidget *parent) : QDialog(parent), currentParams(initial) { - setWindowTitle(tr("Card Art Settings")); + // Legacy stored banners may carry zoom below the gesture floor or an out + // of range offset. Normalize once on open so the preview renders filled + // and Ok saves a state the gestures can reach again + currentParams.zoom = qBound(kMinGestureZoom, currentParams.zoom, kMaxZoom); + currentParams.verticalOffset = qBound(0.0, currentParams.verticalOffset, 1.0); + setMinimumWidth(500); setupUi(); // Seed UI from initial params - if (!initial.cardName.isEmpty()) { - searchBar->setText(initial.cardName); - onCardNameChanged(initial.cardName); + if (!currentParams.cardName.isEmpty()) { + // onCardNameChanged overwrites cardProviderId with the first printing, + // so remember the stored one before it runs + const QString storedProviderId = currentParams.cardProviderId; + searchBar->setText(currentParams.cardName); + onCardNameChanged(currentParams.cardName); // onCardNameChanged leaves the printing combo on the first printing in // the database, which would silently change the stored banner card on // accept. Restore the stored printing when it resolves locally. - const int storedPrintingIndex = providerComboBox->findData(initial.cardProviderId); + const int storedPrintingIndex = providerComboBox->findData(storedProviderId); if (storedPrintingIndex != -1) { providerComboBox->setCurrentIndex(storedPrintingIndex); - } else { + } else if (!storedProviderId.isEmpty()) { // Stored printing not in the local database: keep it rather than // silently substituting the first printing. - currentParams.cardProviderId = initial.cardProviderId; + currentParams.cardProviderId = storedProviderId; reloadPreview(); } } - marginLSpin->setValue(initial.marginPctL); - marginRSpin->setValue(initial.marginPctR); - verticalOffsetSpin->setValue(initial.verticalOffset); - zoomSpin->setValue(initial.zoom); + marginLSpin->setValue(currentParams.marginPctL); + marginRSpin->setValue(currentParams.marginPctR); } CardArtParams UserCardArtSettingsDialog::params() const @@ -150,7 +354,6 @@ QDoubleSpinBox *UserCardArtSettingsDialog::makeSpinBox(double min, double max, d void UserCardArtSettingsDialog::initializeSearchBar() { searchBar = new QLineEdit; - searchBar->setPlaceholderText(tr("Type a card name...")); cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); cardDatabaseDisplayModel = new CardDatabaseDisplayModel(this); @@ -190,29 +393,33 @@ void UserCardArtSettingsDialog::setupUi() marginLSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctL, 0.01); marginRSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctR, 0.01); - verticalOffsetSpin = makeSpinBox(0.0, 1.0, currentParams.verticalOffset, 0.01); - zoomSpin = makeSpinBox(0.1, 4.0, currentParams.zoom, 0.05); auto *form = new QFormLayout; - form->addRow(tr("Card name:"), searchBar); - form->addRow(tr("Card ProviderId:"), providerComboBox); - form->addRow(tr("Left margin (%):"), marginLSpin); - form->addRow(tr("Right margin (%):"), marginRSpin); - form->addRow(tr("Vertical offset:"), verticalOffsetSpin); - form->addRow(tr("Zoom:"), zoomSpin); + cardNameLabel = new QLabel; + printingLabel = new QLabel; + marginLLabel = new QLabel; + marginRLabel = new QLabel; + form->addRow(cardNameLabel, searchBar); + form->addRow(printingLabel, providerComboBox); + form->addRow(marginLLabel, marginLSpin); + form->addRow(marginRLabel, marginRSpin); - auto *controlsGroup = new QGroupBox(tr("Parameters")); + controlsGroup = new QGroupBox; controlsGroup->setLayout(form); preview = new CardArtPreviewWidget; auto *previewLayout = new QVBoxLayout; previewLayout->addWidget(preview); - auto *previewGroup = new QGroupBox(tr("Preview")); + previewCaptionLabel = new QLabel; + previewCaptionLabel->setAlignment(Qt::AlignCenter); + previewCaptionLabel->setWordWrap(true); + previewLayout->addWidget(previewCaptionLabel); + previewGroup = new QGroupBox; previewGroup->setLayout(previewLayout); auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - auto *removeBtn = new QPushButton(tr("Remove Banner Card")); + removeBtn = new QPushButton; buttons->addButton(removeBtn, QDialogButtonBox::ResetRole); connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); @@ -222,16 +429,38 @@ void UserCardArtSettingsDialog::setupUi() accept(); }); + // The banner leads visually, card selection and margins support it below. auto *root = new QVBoxLayout; - root->addWidget(controlsGroup); root->addWidget(previewGroup); + root->addWidget(controlsGroup); root->addWidget(buttons); setLayout(root); connect(marginLSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); connect(marginRSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); - connect(verticalOffsetSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); - connect(zoomSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); + + // Gestures are the only editors of offset and zoom on this surface. + // Margins stay explicit numeric controls: they trim the strip's + // sides and have no natural drag mapping. + connect(preview, &CardArtPreviewWidget::paramsEdited, this, + [this](const CardArtParams &edited) { currentParams = edited; }); + + retranslateUi(); +} + +void UserCardArtSettingsDialog::retranslateUi() +{ + setWindowTitle(tr("Card Art Settings")); + searchBar->setPlaceholderText(tr("Type a card name...")); + cardNameLabel->setText(tr("Card name:")); + printingLabel->setText(tr("Printing:")); + marginLLabel->setText(tr("Left margin (%):")); + marginRLabel->setText(tr("Right margin (%):")); + controlsGroup->setTitle(tr("Card")); + previewCaptionLabel->setText( + tr("Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores.")); + previewGroup->setTitle(tr("Banner")); + removeBtn->setText(tr("Remove Banner Card")); } void UserCardArtSettingsDialog::populateProviderCombo(const QString &cardName) @@ -281,7 +510,7 @@ void UserCardArtSettingsDialog::onCardNameChanged(const QString &name) populateProviderCombo(name); if (providerComboBox->count() == 0) { - // No printings found for this card; nothing to preview. + // No printings found for this card, nothing to preview. currentPixmap = QPixmap(); preview->setPixmap(currentPixmap); currentParams.cardProviderId.clear(); @@ -311,7 +540,7 @@ void UserCardArtSettingsDialog::reloadPreview() // whichever CardInfo we just asked for, so the preview catches up once // the image actually arrives instead of staying on the placeholder. // - // Disconnect any previous listener first -- otherwise switching cards + // Disconnect any previous listener first, otherwise switching cards // repeatedly stacks up connections to old CardInfo objects, each of // which would still fire reloadPreview() (harmlessly, but wastefully) // whenever ITS art finishes loading later. @@ -321,8 +550,8 @@ void UserCardArtSettingsDialog::reloadPreview() CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); if (fullRes.isNull()) { - // Not loaded yet -- wait for the signal instead of giving up. - // card.getCardPtr() is a CardInfoPtr (QSharedPointer); + // Not loaded yet, wait for the signal instead of giving up. + // card.getCardPtr() is a CardInfoPtr (QSharedPointer), // .data() gives the raw QObject* needed for connect(). CardInfo *cardInfo = card.getCardPtr().data(); if (cardInfo) { @@ -345,7 +574,5 @@ void UserCardArtSettingsDialog::onParamChanged() { currentParams.marginPctL = marginLSpin->value(); currentParams.marginPctR = marginRSpin->value(); - currentParams.verticalOffset = verticalOffsetSpin->value(); - currentParams.zoom = zoomSpin->value(); preview->setParams(currentParams); -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h index 397d2b26a..24e14dce5 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h +++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h @@ -8,13 +8,29 @@ #include class QCompleter; +class QFocusEvent; +class QGroupBox; +class QKeyEvent; +class QMouseEvent; class QLineEdit; class QDoubleSpinBox; +class QLabel; +class QPushButton; +class QWheelEvent; class CardDatabaseModel; class CardDatabaseDisplayModel; class CardSearchModel; class CardCompleterProxyModel; +/** + * @brief Interactive preview of the user list banner art. + * + * Renders the banner strip with the given CardArtParams through the same + * UserListPainter::drawCardArt() the live delegate uses, including the + * avatar placeholder and fade masks. Dragging pans the art vertically at + * output scale, the wheel zooms, arrow keys nudge, Backspace or Esc + * restores the parameters as of focus gain. + */ class CardArtPreviewWidget : public QWidget { Q_OBJECT @@ -26,13 +42,31 @@ public: void setParams(const CardArtParams ¶ms); void setAttribution(const QString &attribution); +signals: + /** @brief Emitted whenever direct manipulation (drag, wheel, keys) changes the parameters. */ + void paramsEdited(const CardArtParams ¶ms); + protected: void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void focusInEvent(QFocusEvent *event) override; private: + qreal bannerTravel() const; ///< vertical travel of the art behind the strip, in output pixels + void applyCropDelta(qreal dOffset, qreal zoomFactor); + bool sameCrop(const CardArtParams &a, const CardArtParams &b) const; + void restoreSnapshot(); + QPixmap sourcePixmap; CardArtParams params; + CardArtParams paramsAtFocusIn; ///< crop as of the latest focus gain, restored by Esc or Backspace QString attributionText; + QPoint lastDragPos; ///< widget space position of the previous mouse move while panning + bool dragging{false}; ///< true between an accepted press and its release, guards stale drag positions }; class UserCardArtSettingsDialog : public QDialog @@ -51,6 +85,7 @@ private slots: private: void setupUi(); + void retranslateUi(); void populateProviderCombo(const QString &cardName); void initializeSearchBar(); QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step); @@ -66,14 +101,20 @@ private: QMetaObject::Connection pixmapUpdatedConnection; + QLabel *cardNameLabel; + QLabel *printingLabel; + QLabel *marginLLabel; + QLabel *marginRLabel; + QGroupBox *controlsGroup; + QLabel *previewCaptionLabel; + QGroupBox *previewGroup; + QPushButton *removeBtn; QDoubleSpinBox *marginLSpin; QDoubleSpinBox *marginRSpin; - QDoubleSpinBox *verticalOffsetSpin; - QDoubleSpinBox *zoomSpin; CardArtPreviewWidget *preview; QPixmap currentPixmap; CardArtParams currentParams; }; -#endif // COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H \ No newline at end of file +#endif // COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H From 0b0ec64fe73c9dce3c399c4f907063101691f8fa Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:04:22 +0200 Subject: [PATCH 2/6] [DeckList] Add maybeboard zone constant and skip it in exports (#7174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce DECK_ZONE_MAYBEBOARD and its visible name, and treat the maybeboard as editor-only scratch space: plain-text export, DeckStats and TappedOut uploads now skip cards living there. Zones of this name are created by later custom-zones units; until then the skips are inert. Took 20 minutes Co-authored-by: Lukas Brübach --- .../src/client/network/interfaces/deck_stats_interface.cpp | 2 +- .../src/client/network/interfaces/tapped_out_interface.cpp | 2 +- libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp | 4 ++++ .../libcockatrice/deck_list/tree/inner_deck_list_node.cpp | 2 ++ .../libcockatrice/deck_list/tree/inner_deck_list_node.h | 2 ++ 5 files changed, 10 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp index 8689a19e9..42292b2aa 100644 --- a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp +++ b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp @@ -72,7 +72,7 @@ void DeckStatsInterface::copyDeckWithoutTokens(const DeckList &source, DeckList { auto copyIfNotAToken = [&destination](const auto node, const auto card) { CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName()); - if (dbCard && !dbCard->getIsToken()) { + if (dbCard && !dbCard->getIsToken() && node->getName() != DECK_ZONE_MAYBEBOARD) { DecklistCardNode *addedCard = destination.addCard(card->getName(), node->getName(), -1); addedCard->setNumber(card->getNumber()); } diff --git a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp index 5dc77fa2c..627b7fe34 100644 --- a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp +++ b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp @@ -99,7 +99,7 @@ void TappedOutInterface::copyDeckSplitMainAndSide(const DeckList &source, DeckLi { auto copyMainOrSide = [&mainboard, &sideboard](const auto node, const auto card) { CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName()); - if (!dbCard || dbCard->getIsToken()) { + if (!dbCard || dbCard->getIsToken() || node->getName() == DECK_ZONE_MAYBEBOARD) { return; } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index 4ffc1bab7..1a3876cd3 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp @@ -413,6 +413,10 @@ bool DeckList::loadFromFile_Plain(QIODevice *device, const std::functiongetName() == DECK_ZONE_MAYBEBOARD) { + return; + } if (prefixSideboardCards && node->getName() == DECK_ZONE_SIDE) { stream << "SB: "; } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp index 602ea6aec..1f470695d 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp @@ -28,6 +28,8 @@ QString InnerDecklistNode::visibleNameFromName(const QString &_name) return QObject::tr("Sideboard"); } else if (_name == DECK_ZONE_TOKENS) { return QObject::tr("Tokens"); + } else if (_name == DECK_ZONE_MAYBEBOARD) { + return QObject::tr("Maybeboard"); } else { return _name; } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h index 81c37dffa..f8fdedf30 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h @@ -24,6 +24,8 @@ #define DECK_ZONE_SIDE "side" /** @brief Constant for the "tokens" zone name. */ #define DECK_ZONE_TOKENS "tokens" +/** @brief Constant for the "maybeboard" zone name. */ +#define DECK_ZONE_MAYBEBOARD "maybeboard" /** * @class InnerDecklistNode From fecbbab98388c6fc9bfde8fa1be9927463b95985 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:22:48 +0200 Subject: [PATCH 3/6] [Client] Replace playmat crop spinboxes with a direct manipulation preview (#7161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Replace playmat crop spinboxes with a direct manipulation preview The numeric fields exposed raw parameters with no relation to what the game draws, accepted values the renderer clamps away, and no way to see the result before committing The preview now renders through the exact game pipeline into a viewport shaped like a fresh board stack plus table area, with dimmed strips marking where a developed table crops further. Drag pans, wheel zooms, arrow keys nudge, plus and minus zoom, Backspace or Esc restores the crop as of focus gain and lets Esc close the dialog when unchanged. Focus ring and accessible name and description cover keyboard and screen reader users, new paints use palette roles so themes recolor them Took 25 minutes Took 3 minutes Took 54 seconds * Remove stale constant Took 3 minutes Took 26 seconds * Rebase Took 2 minutes Took 3 seconds * Add editor spinboxes again Took 4 minutes --------- Co-authored-by: Lukas Brübach --- .../playmat/playmat_preview_widget.cpp | 300 +++++++++++++++++- .../widgets/playmat/playmat_preview_widget.h | 32 +- .../playmat/playmat_settings_dialog.cpp | 81 ++++- .../widgets/playmat/playmat_settings_dialog.h | 19 +- 4 files changed, 398 insertions(+), 34 deletions(-) diff --git a/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp index 52f21f714..130daadf4 100644 --- a/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp @@ -3,25 +3,63 @@ #include "../cards/art_crop_attribution.h" #include "playmat_utils.h" +#include #include +#include #include #include +#include +#include + +namespace +{ +// Mirrors the dialog/proto clamps so gestures can never produce an +// out of range parameter. The zoom FLOOR is dynamic: see +// playmatClampedZoom(), zooming out stops where the sampling window would +// exceed the card itself, so there is no dead range at the bottom end. +constexpr qreal MAX_MARGIN = 0.95; +// Range of in game stack+table aspect ratios worth designing for, derived +// from PlayerGraphicsItem::paint()'s combinedArea = stack ∪ table: +// height = 10 + 30 + 3*102 + 2*30 = 406 (TableZone rows) +// width = 1.5*72 + (20 + 5*72 + 15) = 503 (StackZone + TableZone +// at MIN_WIDTH) +// The area's shape depends on GAME CONTENT (played card columns widen the +// table by ~107 px each), not on the window size. Fresh board ≈ 503/406, +// a table grown to roughly double its minimum width ≈ 2.2. +constexpr qreal MIN_TABLE_ASPECT = 503.0 / 406.0; // fresh board: most generous framing +constexpr qreal MAX_TABLE_ASPECT = 2.2; // well developed, wide table +// Keyboard nudge steps (viewport convention: Down looks further down). +constexpr qreal KEY_PAN_MARGIN_STEP = 0.005; +constexpr qreal KEY_PAN_OFFSET_STEP = 0.01; +constexpr qreal KEY_ZOOM_STEP = 1.05; +constexpr qreal WHEEL_ZOOM_BASE = 1.15; // zoom factor per wheel notch +} // namespace PlaymatPreviewWidget::PlaymatPreviewWidget(QWidget *parent) : QWidget(parent) { - setMinimumSize(400, 120); - setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + // The crop is square and drawn contain fit, so the height decides its + // on screen size, keep it generous but let the dialog compress on small + // or high DPI screens + setMinimumSize(400, 180); + QSizePolicy sp(QSizePolicy::Expanding, QSizePolicy::Expanding); + setSizePolicy(sp); + setFocusPolicy(Qt::StrongFocus); + setCursor(Qt::OpenHandCursor); + setAccessibleName(tr("Playmat crop")); + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); } void PlaymatPreviewWidget::setPixmap(const QPixmap &pixmap) { sourcePixmap = pixmap; + setCursor(sourcePixmap.isNull() ? Qt::ArrowCursor : Qt::OpenHandCursor); update(); } void PlaymatPreviewWidget::setParams(const PlaymatParams &p) { params = p; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); update(); } @@ -31,6 +69,94 @@ void PlaymatPreviewWidget::setAttribution(const QString &attribution) update(); } +QRectF PlaymatPreviewWidget::activePlayArea() const +{ + // The viewport is a frame shaped like a fresh board's stack+table area + // (kMinTableAspect): the most generous framing the game will produce. + // Dimmed strips mark where a wider, developed table crops further. + const QRectF cardRect = QRectF(rect()).adjusted(3, 2, -3, -2); + return PlaymatUtils::aspectFitRect(cardRect.adjusted(6, 4, -4, -4), MIN_TABLE_ASPECT); +} + +qreal PlaymatPreviewWidget::samplingWindowSide() const +{ + if (sourcePixmap.isNull()) { + return 0.0; + } + // Same clamped window the render path uses, gestures and painting must + // never disagree about geometry. + return PlaymatUtils::playmatWindowSide(sourcePixmap.size(), params); +} + +qreal PlaymatPreviewWidget::widgetToSourceScale() const +{ + const qreal cropSide = samplingWindowSide(); + const QRectF area = activePlayArea(); + if (cropSide <= 0.0 || area.isEmpty()) { + return 0.0; + } + // Mirror coverFitRect(): the square crop into the (wider) viewport fills + // its width. + return area.width() / cropSide; +} + +void PlaymatPreviewWidget::applyCropDelta(qreal dMarginL, qreal dMarginR, qreal dOffset, qreal zoomFactor) +{ + PlaymatParams next = params; + if (dMarginL + dMarginR == 0.0) { + // Pure horizontal pan rebalances the margins along their + // sum constant segment. Individual bounds must not break that + // invariant, otherwise repeated corner drags let one margin grow + // without end, collapsing the viewing window and desynchronizing the + // visual zoom from the readout. + const qreal sum = params.marginPctL + params.marginPctR; + const qreal lo = qMax(0.0, sum - MAX_MARGIN); + const qreal hi = qMin(sum, MAX_MARGIN); + next.marginPctL = qBound(lo, params.marginPctL + dMarginL, hi); + next.marginPctR = sum - next.marginPctL; + } else { + next.marginPctL = qBound(0.0, params.marginPctL + dMarginL, MAX_MARGIN); + next.marginPctR = qBound(0.0, params.marginPctR + dMarginR, MAX_MARGIN); + } + next.verticalOffset = qBound(0.0, params.verticalOffset + dOffset, 1.0); + // Clamp through the shared helper so the floor tracks the new margins: + // zooming out stops exactly where the window reaches the card bounds. + next.zoom = params.zoom * zoomFactor; + if (!sourcePixmap.isNull()) { + next.zoom = PlaymatUtils::playmatClampedZoom(sourcePixmap.size(), next); + } + + if (sameCrop(next, params)) { + return; + } + + params = next; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); + update(); + emit paramsEdited(params); +} + +bool PlaymatPreviewWidget::sameCrop(const PlaymatParams &a, const PlaymatParams &b) const +{ + // Exact comparison on purpose: clamped assignments yield identical bits, + // while qFuzzyCompare based equality misbehaves around zero + return a.marginPctL == b.marginPctL && a.marginPctR == b.marginPctR && a.verticalOffset == b.verticalOffset && + a.zoom == b.zoom; +} + +void PlaymatPreviewWidget::restoreSnapshot() +{ + // The snapshot only ever holds values that passed the gesture clamps, + // so it is safe to restore verbatim + if (sameCrop(paramsAtFocusIn, params)) { + return; + } + params = paramsAtFocusIn; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); + update(); + emit paramsEdited(params); +} + void PlaymatPreviewWidget::paintEvent(QPaintEvent *) { QPainter painter(this); @@ -56,23 +182,40 @@ void PlaymatPreviewWidget::paintEvent(QPaintEvent *) return; } - // Draw the playmat art using the same logic as PlayerGraphicsItem - // The preview area represents the combined stack+table play area - // Stack is ~20% width on the left, table is ~80% on the right - const QRectF playArea = cardRect.adjusted(6, 4, -4, -4); + const QRectF playArea = activePlayArea(); + // Exactly the game's pipeline (player_graphics_item): cover fit the crop + // into the table shaped viewport, centered, so the frame shows precisely + // what a minimum aspect window shows, and dragging moves the art behind + // the fixed frame. const QRectF srcRect = PlaymatUtils::computeArtSourceRect(sourcePixmap.size(), params); const QRectF dstRect = PlaymatUtils::coverFitRect(playArea, srcRect.size()); painter.setClipRect(playArea.toRect()); painter.drawPixmap(dstRect, sourcePixmap, srcRect); - painter.setClipping(false); + + // Wider (developed) tables crop further: mark where a kMaxTableAspect + // board stops. Palette driven so theme authors can recolor the markers. + const qreal wideBandHeight = playArea.height() * (MIN_TABLE_ASPECT / MAX_TABLE_ASPECT); + const qreal stripHeight = (playArea.height() - wideBandHeight) / 2.0; + QColor stripColor = palette().color(QPalette::Window); + stripColor.setAlpha(150); + painter.fillRect(QRectF(playArea.left(), playArea.top(), playArea.width(), stripHeight), stripColor); + painter.fillRect(QRectF(playArea.left(), playArea.bottom() - stripHeight, playArea.width(), stripHeight), + stripColor); + QColor hairlineColor = palette().color(QPalette::Highlight); + hairlineColor.setAlpha(110); + painter.setPen(QPen(hairlineColor, 1)); + painter.drawLine(QPointF(playArea.left(), playArea.top() + stripHeight), + QPointF(playArea.right(), playArea.top() + stripHeight)); + painter.drawLine(QPointF(playArea.left(), playArea.bottom() - stripHeight), + QPointF(playArea.right(), playArea.bottom() - stripHeight)); // Draw zone divider: stack is roughly the left portion const double stackWidthRatio = 0.18; // Stack is about 18% of total play area const double stackDividerX = playArea.left() + playArea.width() * stackWidthRatio; - // Subtle semi-transparent overlays to distinguish zones + // Subtle semi transparent overlays to distinguish zones // Stack zone overlay (slightly darker) QRectF stackOverlay(playArea.left(), playArea.top(), playArea.width() * stackWidthRatio, playArea.height()); painter.fillRect(stackOverlay, QColor(0, 0, 0, 40)); @@ -89,11 +232,150 @@ void PlaymatPreviewWidget::paintEvent(QPaintEvent *) const double landDividerY = playArea.top() + playArea.height() * 0.65; painter.setPen(QPen(QColor(255, 255, 255, 30), 1)); painter.drawLine(QPointF(stackDividerX, landDividerY), QPointF(playArea.right(), landDividerY)); + painter.setClipping(false); - // Border around entire play area + // Border around the viewport = boundary of every plausible framing. painter.setPen(QPen(QColor(70, 80, 95, 120), 1)); painter.setBrush(Qt::NoBrush); painter.drawRoundedRect(playArea.adjusted(0, 0, -1, -1), 3, 3); + // Visible keyboard focus per the focus cursor contract, Tab must show + // where the keys land + if (hasFocus()) { + QPen focusPen(palette().color(QPalette::Highlight), 2); + painter.setPen(focusPen); + painter.drawRoundedRect(playArea.adjusted(-1, -1, 1, 1), 3, 3); + } + paintArtAttribution(painter, playArea, attributionText, Qt::AlignRight | Qt::AlignBottom, 0.8); + + // Zoom readout so the gesture has a visible, stable counterpart. + QColor ink = palette().color(QPalette::WindowText); + ink.setAlpha(160); + painter.setPen(ink); + painter.drawText(QPointF(playArea.left() + 8, playArea.bottom() - 8), + tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); +} + +void PlaymatPreviewWidget::mousePressEvent(QMouseEvent *event) +{ + if (event->button() != Qt::LeftButton || sourcePixmap.isNull() || samplingWindowSide() <= 0.0) { + QWidget::mousePressEvent(event); + return; + } + lastDragPos = event->pos(); + setCursor(Qt::ClosedHandCursor); + event->accept(); +} + +void PlaymatPreviewWidget::mouseMoveEvent(QMouseEvent *event) +{ + if (!(event->buttons() & Qt::LeftButton) || sourcePixmap.isNull()) { + QWidget::mouseMoveEvent(event); + return; + } + + const QPointF delta = QPointF(event->pos() - lastDragPos); + lastDragPos = event->pos(); + + const qreal scale = widgetToSourceScale(); + // Vertical travel of the SAMPLING window: verticalOffset moves its top + // edge by exactly this much per unit, identical to the render path. + // Windows taller than the art (square/landscape sources zoomed out) + // leave no travel, vertical drags are then boundary no ops. + const qreal travel = static_cast(sourcePixmap.height()) - samplingWindowSide(); + if (scale <= 0.0) { + event->accept(); + return; + } + + // Dragging moves the ART with the cursor, so the viewing window slides the + // other way. Horizontal panning rebalances the margins (their sum, hence + // the window width, stays constant), vertical panning moves the window's + // top edge within its available travel. + const qreal sourceW = sourcePixmap.width(); + const qreal dMargin = -(delta.x() / scale) / sourceW; + const qreal dOffset = travel > 0.5 ? -(delta.y() / scale) / travel : 0.0; + + applyCropDelta(dMargin, -dMargin, dOffset, 1.0); + event->accept(); +} + +void PlaymatPreviewWidget::mouseReleaseEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) { + setCursor(sourcePixmap.isNull() ? Qt::ArrowCursor : Qt::OpenHandCursor); + event->accept(); + return; + } + QWidget::mouseReleaseEvent(event); +} + +void PlaymatPreviewWidget::wheelEvent(QWheelEvent *event) +{ + if (sourcePixmap.isNull() || samplingWindowSide() <= 0.0) { + QWidget::wheelEvent(event); + return; + } + const qreal notches = static_cast(event->angleDelta().y()) / 120.0; + if (notches == 0.0) { + event->accept(); + return; + } + applyCropDelta(0.0, 0.0, 0.0, std::pow(WHEEL_ZOOM_BASE, notches)); + event->accept(); +} + +void PlaymatPreviewWidget::keyPressEvent(QKeyEvent *event) +{ + if (sourcePixmap.isNull()) { + QWidget::keyPressEvent(event); + return; + } + + switch (event->key()) { + case Qt::Key_Escape: + if (sameCrop(params, paramsAtFocusIn)) { + // Nothing to undo on this surface, let the event reach the + // dialog so Esc keeps its close meaning there + QWidget::keyPressEvent(event); + return; + } + restoreSnapshot(); + break; + case Qt::Key_Backspace: + restoreSnapshot(); + break; + case Qt::Key_Left: + applyCropDelta(-KEY_PAN_MARGIN_STEP, KEY_PAN_MARGIN_STEP, 0.0, 1.0); + break; + case Qt::Key_Right: + applyCropDelta(KEY_PAN_MARGIN_STEP, -KEY_PAN_MARGIN_STEP, 0.0, 1.0); + break; + case Qt::Key_Up: + applyCropDelta(0.0, 0.0, -KEY_PAN_OFFSET_STEP, 1.0); + break; + case Qt::Key_Down: + applyCropDelta(0.0, 0.0, KEY_PAN_OFFSET_STEP, 1.0); + break; + case Qt::Key_Plus: + case Qt::Key_Equal: + applyCropDelta(0.0, 0.0, 0.0, KEY_ZOOM_STEP); + break; + case Qt::Key_Minus: + applyCropDelta(0.0, 0.0, 0.0, 1.0 / KEY_ZOOM_STEP); + break; + default: + QWidget::keyPressEvent(event); + return; + } + event->accept(); +} + +void PlaymatPreviewWidget::focusInEvent(QFocusEvent *event) +{ + // Snapshot for the Esc or Backspace reset, restoring whatever the user + // had when the surface took focus + paramsAtFocusIn = params; + QWidget::focusInEvent(event); } diff --git a/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.h b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.h index 55771192f..88b9ec41d 100644 --- a/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.h +++ b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.h @@ -1,16 +1,23 @@ #ifndef COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H #define COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H +#include #include #include #include /** - * @brief Preview widget that shows how a playmat card art will appear + * @brief Interactive crop surface showing how a playmat card art will appear * across the combined table + stack play area. * - * Renders a miniature mockup with the card art applied using the - * given PlaymatParams, including faint zone divider lines. + * Renders a fixed frame shaped like a fresh board's stack+table area (the + * most generous framing the game produces): it shows the tallest slice of + * the square crop in normal play, with dimmed strips marking where a wider, + * developed table crops further, exactly the game's own render pipeline. + * The widget doubles as the editor's primary crop control: dragging pans the + * art behind the frame, the wheel zooms, and arrow keys nudge, mirroring + * the stored parameters (margins pan horizontally, verticalOffset + * vertically, zoom scales) so no separate numeric controls are needed. */ class PlaymatPreviewWidget : public QWidget { @@ -23,13 +30,32 @@ public: void setParams(const PlaymatParams ¶ms); void setAttribution(const QString &attribution); +signals: + /** @brief Emitted whenever direct manipulation (drag, wheel, keys) changes the crop parameters. */ + void paramsEdited(const PlaymatParams ¶ms); + protected: void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void focusInEvent(QFocusEvent *event) override; private: + QRectF activePlayArea() const; ///< destination rect used for rendering AND gesture math + qreal samplingWindowSide() const; ///< clamped square window side, shared with the render path + qreal widgetToSourceScale() const; + void applyCropDelta(qreal dMarginL, qreal dMarginR, qreal dOffset, qreal zoomFactor); + bool sameCrop(const PlaymatParams &a, const PlaymatParams &b) const; + void restoreSnapshot(); + QPixmap sourcePixmap; PlaymatParams params; + PlaymatParams paramsAtFocusIn; ///< crop as of the latest focus gain, restored by Esc or Backspace QString attributionText; + QPoint lastDragPos; ///< widget space position of the previous mouse move while panning }; #endif // COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H diff --git a/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp index 72c715e13..57706cf93 100644 --- a/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp +++ b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp @@ -7,6 +7,7 @@ #include "card_database_model.h" #include "playmat_preview_widget.h" +#include #include #include #include @@ -48,10 +49,6 @@ PlaymatSettingsDialog::PlaymatSettingsDialog(const CardRef &initialCard, reloadPreview(); } } - marginLSpin->setValue(initialParams.marginPctL); - marginRSpin->setValue(initialParams.marginPctR); - verticalOffsetSpin->setValue(initialParams.verticalOffset); - zoomSpin->setValue(initialParams.zoom); retranslateUi(); } @@ -112,23 +109,32 @@ void PlaymatSettingsDialog::setupUi() connect(providerComboBox, &QComboBox::currentIndexChanged, this, [this]() { currentCard.providerId = providerComboBox->currentData().toString(); reloadPreview(); - onParamChanged(); }); + auto *form = new QFormLayout; + controlsForm = form; + cardNameLabel = new QLabel; + printingLabel = new QLabel; + form->addRow(cardNameLabel, searchBar); + form->addRow(printingLabel, providerComboBox); + + // Numerical editors expose the raw PlaymatParams for precise input. They + // share the same form as the rows above so every field lines up on one + // label column. They stay hidden until requested since the crop surface + // is the primary control. marginLSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctL, 0.01); marginRSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctR, 0.01); verticalOffsetSpin = makeSpinBox(0.0, 1.0, currentParams.verticalOffset, 0.01); zoomSpin = makeSpinBox(0.1, 4.0, currentParams.zoom, 0.05); - auto *form = new QFormLayout; - cardNameLabel = new QLabel; - printingLabel = new QLabel; leftMarginLabel = new QLabel; rightMarginLabel = new QLabel; verticalOffsetLabel = new QLabel; zoomLabel = new QLabel; - form->addRow(cardNameLabel, searchBar); - form->addRow(printingLabel, providerComboBox); + + showNumericEditorsCheck = new QCheckBox; + + form->addRow(showNumericEditorsCheck); form->addRow(leftMarginLabel, marginLSpin); form->addRow(rightMarginLabel, marginRSpin); form->addRow(verticalOffsetLabel, verticalOffsetSpin); @@ -138,9 +144,14 @@ void PlaymatSettingsDialog::setupUi() controlsGroup->setLayout(form); preview = new PlaymatPreviewWidget; + preview->setParams(currentParams); auto *previewLayout = new QVBoxLayout; previewLayout->addWidget(preview); + previewCaptionLabel = new QLabel; + previewCaptionLabel->setAlignment(Qt::AlignCenter); + previewCaptionLabel->setWordWrap(true); + previewLayout->addWidget(previewCaptionLabel); previewGroup = new QGroupBox; previewGroup->setLayout(previewLayout); @@ -155,16 +166,35 @@ void PlaymatSettingsDialog::setupUi() accept(); }); - auto *root = new QVBoxLayout; - root->addWidget(controlsGroup); - root->addWidget(previewGroup); - root->addWidget(buttons); - setLayout(root); + // The crop surface is the primary control: dragging pans, wheel/keys zoom, + // editing exactly the same stored parameters the numeric fields do. + connect(preview, &PlaymatPreviewWidget::paramsEdited, this, [this](const PlaymatParams &edited) { + currentParams = edited; + + QSignalBlocker blockMarginL(marginLSpin); + QSignalBlocker blockMarginR(marginRSpin); + QSignalBlocker blockOffset(verticalOffsetSpin); + QSignalBlocker blockZoom(zoomSpin); + marginLSpin->setValue(edited.marginPctL); + marginRSpin->setValue(edited.marginPctR); + verticalOffsetSpin->setValue(edited.verticalOffset); + zoomSpin->setValue(edited.zoom); + }); + + connect(showNumericEditorsCheck, &QCheckBox::toggled, this, &PlaymatSettingsDialog::setNumericEditorsVisible); + setNumericEditorsVisible(false); connect(marginLSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged); connect(marginRSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged); connect(verticalOffsetSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged); connect(zoomSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged); + + // The crop surface leads visually, card selection supports it below. + auto *root = new QVBoxLayout; + root->addWidget(previewGroup); + root->addWidget(controlsGroup); + root->addWidget(buttons); + setLayout(root); } void PlaymatSettingsDialog::populateProviderCombo(const QString &cardName) @@ -261,17 +291,34 @@ void PlaymatSettingsDialog::onParamChanged() preview->setParams(currentParams); } +void PlaymatSettingsDialog::setNumericEditorsVisible(bool visible) +{ + controlsForm->setRowVisible(leftMarginLabel, visible); + controlsForm->setRowVisible(rightMarginLabel, visible); + controlsForm->setRowVisible(verticalOffsetLabel, visible); + controlsForm->setRowVisible(zoomLabel, visible); + + // A QDialog never resizes itself when its content requirements change, + // so revealing the editors would squeeze the crop group until the info + // caption ran into the preview. Re-fit the dialog to the new size hint. + adjustSize(); +} + void PlaymatSettingsDialog::retranslateUi() { setWindowTitle(tr("Playmat Settings")); searchBar->setPlaceholderText(tr("Type a card name...")); cardNameLabel->setText(tr("Card name:")); printingLabel->setText(tr("Printing:")); + showNumericEditorsCheck->setText(tr("Show numerical editors")); leftMarginLabel->setText(tr("Left margin (%):")); rightMarginLabel->setText(tr("Right margin (%):")); verticalOffsetLabel->setText(tr("Vertical offset:")); zoomLabel->setText(tr("Zoom:")); - controlsGroup->setTitle(tr("Parameters")); - previewGroup->setTitle(tr("Preview")); + controlsGroup->setTitle(tr("Card")); + previewGroup->setTitle(tr("Crop")); + previewCaptionLabel->setText( + tr("Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. " + "Dimmed strips mark where a wider table crops further.")); removeButton->setText(tr("Remove Playmat")); } diff --git a/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.h b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.h index 7ccd1569d..9ef306a5f 100644 --- a/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.h +++ b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.h @@ -5,13 +5,16 @@ #include #include +class QCheckBox; class QComboBox; class QCompleter; class QDoubleSpinBox; +class QFormLayout; class QGroupBox; class QLabel; class QLineEdit; class QPushButton; +class QWidget; class CardDatabaseModel; class CardDatabaseDisplayModel; class CardSearchModel; @@ -21,10 +24,11 @@ class PlaymatPreviewWidget; /** * @brief Dialog for configuring the playmat card art for a deck. * - * Allows the user to select a card from the database and adjust - * positioning parameters (margins, zoom, vertical offset) for how - * the card art appears as a playmat background across the - * combined table + stack play area. + * The crop surface is the primary control: drag to pan the visible art, + * scroll (or +/- keys) to zoom, arrow keys to nudge. Card name and printing + * are selected below. A checkbox reveals optional numerical editors for the + * raw PlaymatParams. These controls edit the same stored PlaymatParams that + * ship in deck files and player properties. */ class PlaymatSettingsDialog : public QDialog { @@ -40,14 +44,15 @@ public: private slots: void onCardNameChanged(const QString &name); - void reloadPreview(); void onParamChanged(); + void reloadPreview(); private: void setupUi(); void populateProviderCombo(const QString &cardName); void initializeSearchBar(); void retranslateUi(); + void setNumericEditorsVisible(bool visible); QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step); QLineEdit *searchBar; @@ -63,6 +68,9 @@ private: QLabel *cardNameLabel; QLabel *printingLabel; + QLabel *previewCaptionLabel; + QCheckBox *showNumericEditorsCheck; + QFormLayout *controlsForm; QLabel *leftMarginLabel; QLabel *rightMarginLabel; QLabel *verticalOffsetLabel; @@ -75,6 +83,7 @@ private: QDoubleSpinBox *marginRSpin; QDoubleSpinBox *verticalOffsetSpin; QDoubleSpinBox *zoomSpin; + PlaymatPreviewWidget *preview; QPixmap currentPixmap; From 22b0f69706d3c44577d7c6738dcda0fd837bd7ce Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:25:14 +0200 Subject: [PATCH 4/6] [UserList] Request banner card art for visible rows and hovered popup (#7172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../widgets/server/user/user_list_widget.cpp | 54 ++++++++++++------- .../widgets/server/user/user_list_widget.h | 2 +- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index e71eac23b..779ee1b9c 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -718,12 +718,20 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, } }); - // Hide popup when list scrolls (reference row has moved) + // Section dividers can be collapsed/expanded by the user. Surface those + // changes only from real user interaction. Programmatic expansion is + // applied through setSectionExpanded() / setExpandedProgrammatically(). + connect(userTree, &QTreeWidget::itemExpanded, this, + [this](QTreeWidgetItem *item) { handleSectionExpansion(item, true); }); + connect(userTree, &QTreeWidget::itemCollapsed, this, + [this](QTreeWidgetItem *item) { handleSectionExpansion(item, false); }); + + // Hide popup when list scrolls (reference row has moved) connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] { - showPopupTimer->stop(); - hidePopup(true); - requestAvatarsForVisibleItems(); - }); + showPopupTimer->stop(); + hidePopup(true); + requestAvatarsForVisibleItems(); + }); // Forward join requests from popup upward connect(userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested); @@ -915,7 +923,7 @@ void UserListWidget::showEvent(QShowEvent *e) if (!userInfoPopup) { return; } - requestAvatarsForVisibleItems(); + requestVisibleItemResources(); } void UserListWidget::applyDisplayMode() @@ -1075,6 +1083,10 @@ void UserListWidget::showPopupForUser(UserListTWI *item) const QString userName = QString::fromStdString(item->getUserInfo().name()); avatarProvider->requestAvatar(userName); // ensure the hovered user's avatar is fetched promptly + if (cardArtParamsMap.contains(userName)) { + const CardArtParams ¶ms = cardArtParamsMap.value(userName); + cardArtProvider->requestCardArt(userName, params.cardName, params.cardProviderId); + } const ServerInfo_User &info = item->getUserInfo(); const bool online = item->data(0, UserListRoles::Online).toBool(); @@ -1256,7 +1268,7 @@ void UserListWidget::endBulkLoad() bulkLoading = false; sortItems(); updateCount(); // divider counts were deferred during the bulk build - requestAvatarsForVisibleItems(); + requestVisibleItemResources(); userTree->viewport()->update(); } @@ -1269,8 +1281,20 @@ bool UserListWidget::isItemNearViewport(const UserListTWI *item) const return userTree->visualItemRect(item).intersects(nearView); } -void UserListWidget::requestAvatarsForVisibleItems() +void UserListWidget::requestVisibleItemResources() { + const auto requestResources = [this](UserListTWI *twi) { + if (!isItemNearViewport(twi)) { + return; + } + const QString userName = QString::fromStdString(twi->getUserInfo().name()); + avatarProvider->requestAvatar(userName); + if (cardArtParamsMap.contains(userName)) { + const CardArtParams ¶ms = cardArtParamsMap.value(userName); + cardArtProvider->requestCardArt(userName, params.cardName, params.cardProviderId); + } + }; + if (sectioned) { // Top level items are dividers, user rows hang below them. for (const Section section : sectionIds) { @@ -1279,20 +1303,14 @@ void UserListWidget::requestAvatarsForVisibleItems() continue; } for (int i = 0; i < divider->childCount(); ++i) { - auto *twi = static_cast(divider->child(i)); - if (isItemNearViewport(twi)) { - avatarProvider->requestAvatar(QString::fromStdString(twi->getUserInfo().name())); - } + requestResources(static_cast(divider->child(i))); } } return; } for (int i = 0; i < userTree->topLevelItemCount(); ++i) { - auto *twi = static_cast(userTree->topLevelItem(i)); - if (isItemNearViewport(twi)) { - avatarProvider->requestAvatar(QString::fromStdString(twi->getUserInfo().name())); - } + requestResources(static_cast(userTree->topLevelItem(i))); } } @@ -1572,7 +1590,7 @@ void UserListWidget::applyFilter() } updateSectionDivider(section); } - requestAvatarsForVisibleItems(); + requestVisibleItemResources(); userTree->viewport()->update(); emit userListChanged(); return; @@ -1590,7 +1608,7 @@ void UserListWidget::applyFilter() } } - requestAvatarsForVisibleItems(); + requestVisibleItemResources(); userTree->viewport()->update(); emit userListChanged(); } diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_widget.h index e7a5116ef..5fed54573 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -189,7 +189,7 @@ private: bool isPressInsideListUi(const QWidget *widget) const; void clearSelectionAndClosePopup(); bool isItemNearViewport(const UserListTWI *item) const; - void requestAvatarsForVisibleItems(); + void requestVisibleItemResources(); // Sectioned mode (single tree with inline dividers) bool sectioned = false; From b13c682a7ac95fdec7b08465b8d514df5cbe310a Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:44:05 +0200 Subject: [PATCH 5/6] [DeckList] Make deck tree card traversal recursive (#7175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [DeckList] Make deck tree card traversal recursive getCardNodes and forEachCard now descend into nested zones instead of assuming a flat main/side/token layout. For today's flat trees this is behavior-preserving; it also removes two latent crashes (unchecked dynamic_cast dereference, null card nodes passed to forEachCard callers). Nested zones are introduced by later custom-zones units. Took 3 minutes Took 7 seconds Took 9 seconds * Fix rebase mistake. --------- Co-authored-by: Lukas Brübach --- .../widgets/server/user/user_list_widget.cpp | 30 +++++++-------- .../deck_list/deck_list_node_tree.cpp | 37 +++++++++++++------ .../deck_list/deck_list_node_tree.h | 5 ++- 3 files changed, 44 insertions(+), 28 deletions(-) diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index 779ee1b9c..c4b5d6af6 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -718,20 +718,20 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, } }); - // Section dividers can be collapsed/expanded by the user. Surface those - // changes only from real user interaction. Programmatic expansion is - // applied through setSectionExpanded() / setExpandedProgrammatically(). - connect(userTree, &QTreeWidget::itemExpanded, this, - [this](QTreeWidgetItem *item) { handleSectionExpansion(item, true); }); - connect(userTree, &QTreeWidget::itemCollapsed, this, - [this](QTreeWidgetItem *item) { handleSectionExpansion(item, false); }); + // Section dividers can be collapsed/expanded by the user. Surface those + // changes only from real user interaction. Programmatic expansion is + // applied through setSectionExpanded() / setExpandedProgrammatically(). + connect(userTree, &QTreeWidget::itemExpanded, this, + [this](QTreeWidgetItem *item) { handleSectionExpansion(item, true); }); + connect(userTree, &QTreeWidget::itemCollapsed, this, + [this](QTreeWidgetItem *item) { handleSectionExpansion(item, false); }); - // Hide popup when list scrolls (reference row has moved) + // Hide popup when list scrolls (reference row has moved) connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] { - showPopupTimer->stop(); - hidePopup(true); - requestAvatarsForVisibleItems(); - }); + showPopupTimer->stop(); + hidePopup(true); + requestVisibleItemResources(); + }); // Forward join requests from popup upward connect(userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested); @@ -746,7 +746,7 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, // Keep the popup-less scroll path alive for avatar prefetch. connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, - [this] { requestAvatarsForVisibleItems(); }); + [this] { requestVisibleItemResources(); }); } // Section dividers can be collapsed/expanded by the user. Surface those @@ -1541,9 +1541,9 @@ void UserListWidget::updateCount() } } -void UserListWidget::setShowTitle(bool showTitle) +void UserListWidget::setShowTitle(bool _showTitle) { - this->showTitle = showTitle; + this->showTitle = _showTitle; updateCount(); } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp index 196416cde..efe20595b 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp @@ -41,13 +41,19 @@ QList DecklistNodeTree::getCardNodes(const QSet result; - for (auto *zoneNode : getZoneNodes(restrictToZones)) { - for (auto *cardNode : *zoneNode) { - auto *cardCardNode = dynamic_cast(cardNode); - if (cardCardNode) { - result.append(cardCardNode); + std::function collectCards = [&collectCards, + &result](const InnerDecklistNode *node) { + for (int i = 0; i < node->size(); i++) { + if (auto *card = dynamic_cast(node->at(i))) { + result.append(card); + } else if (auto *inner = dynamic_cast(node->at(i))) { + collectCards(inner); } } + }; + + for (auto *zoneNode : getZoneNodes(restrictToZones)) { + collectCards(zoneNode); } return result; @@ -160,13 +166,22 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode void DecklistNodeTree::forEachCard(const std::function &func) const { - // Support for this is only possible if the internal structure - // doesn't get more complicated. + // Cards nested in custom zones are reported with their top-level board zone + // so that callers can classify cards by board (main/side/maybeboard/tokens). + std::function walk = [&func, &walk](InnerDecklistNode *boardZone, + InnerDecklistNode *node) { + for (int i = 0; i < node->size(); i++) { + if (auto *card = dynamic_cast(node->at(i))) { + func(boardZone, card); + } else if (auto *inner = dynamic_cast(node->at(i))) { + walk(boardZone, inner); + } + } + }; + for (int i = 0; i < root->size(); i++) { - InnerDecklistNode *node = dynamic_cast(root->at(i)); - for (int j = 0; j < node->size(); j++) { - DecklistCardNode *card = dynamic_cast(node->at(j)); - func(node, card); + if (auto *zone = dynamic_cast(root->at(i))) { + walk(zone, zone); } } } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h index 8ef0b18a5..eae20aa23 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h @@ -78,9 +78,10 @@ public: bool deleteNode(AbstractDecklistNode *node, InnerDecklistNode *rootNode = nullptr); /** - * @brief Apply a function to every card in the deck tree. This can modify the cards. + * @brief Applies a function to every card in the deck tree. This can modify the cards. * - * @param func Function taking (zone node, card node). + * @param func Function taking (top-level board zone node, card node). Cards nested + * in custom zones are reported with their board zone. */ void forEachCard(const std::function &func) const; From 24d8d8be3b7fdf9f3a89ff3124c42ddfd3b67d0b Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:26:41 +0200 Subject: [PATCH 6/6] [VDS] Cache mana symbol renders and skip redundant resizes (#7167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [VDS] Cache mana symbol renders and skip redundant resizes - Render each mana symbol once at a bounded master size and derive every requested size from the cached master, avoiding repeated full-size SVG rasterization on the GUI thread - Share scaled results through a process-wide cache keyed by symbol and size, so repeated widget creation and rescales don't redo the work - Skip redundant resize work in ColorIdentityWidget and ManaSymbolWidget when sizes did not change Took 8 minutes Took 50 seconds * Move to pixmap generator Took 8 minutes Took 4 seconds --------- Co-authored-by: Lukas Brübach --- .../src/interface/pixel_map_generator.cpp | 52 +++++++++++++++++++ .../src/interface/pixel_map_generator.h | 29 +++++++++++ .../additional_info/color_identity_widget.cpp | 41 +++++++++++---- .../additional_info/color_identity_widget.h | 2 + .../additional_info/mana_symbol_widget.cpp | 21 ++++---- .../additional_info/mana_symbol_widget.h | 3 -- .../utility/card_completer_delegate.cpp | 28 ++-------- .../widgets/utility/card_completer_delegate.h | 6 --- cockatrice/src/main.cpp | 1 + 9 files changed, 127 insertions(+), 56 deletions(-) diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index 5bfba1c8a..9b8c4bcdc 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -418,6 +419,57 @@ QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded) QMap DropdownIconPixmapGenerator::pmCache; +namespace +{ +/// Longest side mana symbols are rendered at before being scaled to their final size. +constexpr int MASTER_ICON_SIZE = 128; + +QString manaSymbolCacheKey(const QString &symbol, const QSize &size) +{ + return symbol + QLatin1Char('|') + QString::number(size.width()) + QLatin1Char('x') + + QString::number(size.height()); +} +} // namespace + +const QPixmap &ManaSymbolPixmapGenerator::masterIcon(const QString &symbol) +{ + auto it = masterCache.constFind(symbol); + if (it != masterCache.constEnd()) { + return it.value(); + } + + QImageReader reader("theme:icons/mana/" + symbol); + QSize sourceSize = reader.size(); + if (!sourceSize.isEmpty()) { + sourceSize.scale(QSize(MASTER_ICON_SIZE, MASTER_ICON_SIZE), Qt::KeepAspectRatio); + reader.setScaledSize(sourceSize); + } + const QPixmap rendered = QPixmap::fromImageReader(&reader); + + return masterCache.insert(symbol, rendered).value(); +} + +QPixmap ManaSymbolPixmapGenerator::generatePixmap(const QString &symbol, const QSize &size) +{ + const QString key = manaSymbolCacheKey(symbol, size); + auto it = scaledCache.constFind(key); + if (it != scaledCache.constEnd()) { + return it.value(); + } + + const QPixmap &icon = masterIcon(symbol); + if (icon.isNull()) { + return {}; + } + + QPixmap scaled = icon.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation); + scaledCache.insert(key, scaled); + return scaled; +} + +QHash ManaSymbolPixmapGenerator::masterCache; +QHash ManaSymbolPixmapGenerator::scaledCache; + QPixmap loadColorAdjustedPixmap(const QString &name) { if (qApp->palette().windowText().color().lightness() > 200) { diff --git a/cockatrice/src/interface/pixel_map_generator.h b/cockatrice/src/interface/pixel_map_generator.h index 22f44d8db..17720166a 100644 --- a/cockatrice/src/interface/pixel_map_generator.h +++ b/cockatrice/src/interface/pixel_map_generator.h @@ -7,6 +7,7 @@ #ifndef PIXMAPGENERATOR_H #define PIXMAPGENERATOR_H +#include #include #include #include @@ -125,6 +126,34 @@ public: } }; +class ManaSymbolPixmapGenerator +{ +private: + static QHash masterCache; + static QHash scaledCache; + + /** + * @brief Renders \a symbol once at a fixed moderate size, so repeated scalings never + * re-rasterize the source file (SVG sources can be very expensive to rasterize). + */ + static const QPixmap &masterIcon(const QString &symbol); + +public: + /** + * @brief Returns a smooth-scaled rendering of the given mana symbol icon. + * + * Results are shared between all callers via a process-wide cache keyed by symbol + * and size, so scaling work is done once per distinct combination instead of once + * per widget creation or resize. + */ + static QPixmap generatePixmap(const QString &symbol, const QSize &size); + static void clear() + { + masterCache.clear(); + scaledCache.clear(); + } +}; + QPixmap loadColorAdjustedPixmap(const QString &name); #endif diff --git a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp index 3f0f30a27..a4cb86751 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp @@ -41,6 +41,11 @@ void ColorIdentityWidget::populateManaSymbolWidgets() // clear old layout QtUtils::clearLayoutRec(layout); + // The freshly created symbols haven't been sized yet, so force the next resize pass + // to apply the symbol size again. + lastIconSize = -1; + lastWidth = -1; + // populate mana symbols if (SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDrawUnusedColorIdentities()) { for (const QString symbol : fullColorIdentity) { @@ -73,21 +78,35 @@ void ColorIdentityWidget::toggleUnusedVisibility() void ColorIdentityWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); + + // Layout passes resize this widget repeatedly with identical sizes, so bail out before + // touching the children when neither the width nor the resulting symbol size changed. + const int totalWidth = event->size().width(); + if (totalWidth == lastWidth && lastIconSize != -1) { + return; + } + lastWidth = totalWidth; + + const int totalHeight = totalWidth / 6; // Set height to 1/4 of the width + setFixedHeight(totalHeight); + QList manaSymbols = findChildren(); + if (manaSymbols.isEmpty()) { + return; + } - if (!manaSymbols.isEmpty()) { - int totalWidth = event->size().width(); - int totalHeight = totalWidth / 6; // Set height to 1/4 of the width - setFixedHeight(totalHeight); + const int spacing = layout->spacing(); + const int count = manaSymbols.size(); + const int availableWidth = totalWidth - (spacing * (count - 1)); + const int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height - int spacing = layout->spacing(); - int count = manaSymbols.size(); - int availableWidth = totalWidth - (spacing * (count - 1)); - int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height + if (iconSize == lastIconSize) { + return; + } + lastIconSize = iconSize; - for (ManaSymbolWidget *manaSymbol : manaSymbols) { - manaSymbol->setFixedSize(iconSize, iconSize); - } + for (ManaSymbolWidget *manaSymbol : manaSymbols) { + manaSymbol->setFixedSize(iconSize, iconSize); } } diff --git a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.h b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.h index f776d4c77..315ac07d6 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.h +++ b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.h @@ -30,6 +30,8 @@ public slots: private: QString colorIdentity; QHBoxLayout *layout; + int lastIconSize = -1; ///< The symbol size last applied, to skip redundant resize passes. + int lastWidth = -1; ///< The width last processed, to skip redundant resize passes. }; #endif // COLOR_IDENTITY_WIDGET_H diff --git a/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp index 51247da7a..18011909f 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp @@ -1,15 +1,15 @@ #include "mana_symbol_widget.h" #include "../../../../client/settings/cache_settings.h" +#include "../../../pixel_map_generator.h" #include #include ManaSymbolWidget::ManaSymbolWidget(QWidget *parent, QString _symbol, bool _isActive, bool _mayBeToggled) - : QLabel(parent), symbol(_symbol), isActive(_isActive), mayBeToggled(_mayBeToggled) + : QLabel(parent), symbol(std::move(_symbol)), isActive(_isActive), mayBeToggled(_mayBeToggled) { - loadManaIcon(); - setPixmap(manaIcon.scaled(50, 50, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + setPixmap(ManaSymbolPixmapGenerator::generatePixmap(symbol, QSize(50, 50))); setMaximumWidth(50); // Initialize opacity effect @@ -64,16 +64,13 @@ void ManaSymbolWidget::mousePressEvent(QMouseEvent *event) void ManaSymbolWidget::resizeEvent(QResizeEvent *event) { QLabel::resizeEvent(event); - setPixmap(manaIcon.scaled(event->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); -} + const QSize newSize = event->size(); -void ManaSymbolWidget::loadManaIcon() -{ - QString filename = "theme:icons/mana/"; - - if (symbol == "W" || symbol == "U" || symbol == "B" || symbol == "R" || symbol == "G") { - filename += symbol; + // Skip the rescale when the size didn't actually change: layout passes resize these + // widgets repeatedly with identical sizes. + if (newSize.isEmpty() || pixmap().size() == newSize) { + return; } - manaIcon = QPixmap(filename); + setPixmap(ManaSymbolPixmapGenerator::generatePixmap(symbol, newSize)); } diff --git a/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.h b/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.h index 0f2d7acd1..705873dff 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.h +++ b/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.h @@ -33,8 +33,6 @@ public: return symbol[0]; } - void loadManaIcon(); - public slots: void resizeEvent(QResizeEvent *event) override; void mousePressEvent(QMouseEvent *event) override; @@ -44,7 +42,6 @@ signals: private: QString symbol; - QPixmap manaIcon; bool isActive; bool mayBeToggled; QGraphicsOpacityEffect *opacityEffect; diff --git a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp index 6cf096cd4..29bf2263e 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp @@ -1,5 +1,6 @@ #include "card_completer_delegate.h" +#include "../../pixel_map_generator.h" #include "../cards/additional_info/mana_cost_widget.h" #include @@ -84,7 +85,6 @@ QColor CardCompleterDelegate::accentForColors(const QString &colors) CardCompleterDelegate::CardCompleterDelegate(QObject *parent) : QStyledItemDelegate(parent) { - symbolCache.setMaxCost(64); setCodeCache.setMaxCost(64); } @@ -108,36 +108,16 @@ QSize CardCompleterDelegate::sizeHint(const QStyleOptionViewItem &option, const // Mana symbol painting // --------------------------------------------------------------------------- -const QPixmap *CardCompleterDelegate::cachedSymbolPixmap(const QString &symbol, int size) const -{ - const QString key = symbol + QString::number(size); - - if (symbolCache.contains(key)) { - return symbolCache[key]; - } - - QPixmap src(QString("theme:icons/mana/%1").arg(symbol)); - - if (!src.isNull()) { - auto *pm = new QPixmap(src.scaled(size, size, Qt::KeepAspectRatio, Qt::SmoothTransformation)); - - symbolCache.insert(key, pm); - return pm; - } - - return nullptr; -} - // --------------------------------------------------------------------------- void CardCompleterDelegate::drawManaSymbol(QPainter *p, QPoint centre, const QString &symbol, int radius) const { const QRect pip(centre.x() - radius, centre.y() - radius, radius * 2, radius * 2); - const QPixmap *px = cachedSymbolPixmap(symbol, radius * 2); + const QPixmap px = ManaSymbolPixmapGenerator::generatePixmap(symbol, QSize(radius * 2, radius * 2)); - if (px && !px->isNull()) { - p->drawPixmap(pip, *px); + if (!px.isNull()) { + p->drawPixmap(pip, px); return; } diff --git a/cockatrice/src/interface/widgets/utility/card_completer_delegate.h b/cockatrice/src/interface/widgets/utility/card_completer_delegate.h index 18661a21b..78667f56b 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_delegate.h +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.h @@ -31,9 +31,6 @@ public: QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; private: - // Mana symbol pixmaps, loaded once and cached - mutable QCache symbolCache; - // Set short codes, resolved once per card name and cached mutable QCache setCodeCache; @@ -47,9 +44,6 @@ private: // adventure costs ("1W // W") are drawn as separate groups. Returns the left-most x used int drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius) const; - // Load (or return cached) a mana icon pixmap; falls back to painted circle - const QPixmap *cachedSymbolPixmap(const QString &symbol, int size) const; - // Resolve the preferred printing's set short code for a card QString setCodeForCard(const QSharedPointer &card) const; diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 814da9808..84d5d175f 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -390,6 +390,7 @@ int main(int argc, char *argv[]) PingPixmapGenerator::clear(); CountryPixmapGenerator::clear(); UserLevelPixmapGenerator::clear(); + ManaSymbolPixmapGenerator::clear(); return ret; }