From 3d8c729517b776329172713c3fd803a708b89ae1 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Mon, 9 Nov 2026 06:59:53 -0500 Subject: [PATCH] [Client] Preview token edits live and preserve custom drafts (#7737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Preview token edits live and preserve custom drafts * Restore the upstream reveal-random-hand-cards action The rebase onto upstream/master took the PR side of player_actions.cpp and dropped PlayerActions::actRevealRandomHandCards, which upstream added after the PR merge base. Re-apply the face-down P/T fix on the current file so the branch keeps both changes. * Draw the token preview P/T as the hover-panel pill The preview painted the name and P/T unconditionally: the name duplicated the artwork, and the raw P/T text was drawn even when it matched the printed value, which looked wrong at non-default dialog sizes. - Suppress the card name over a token picture via a small AbstractCardItem hook; cards without art still show it. - Draw only a P/T that differs from the printed getPowTough() as the #7700 hover-panel pill: dark rounded scrim, bold white text, anchored on the printed P/T box and clamped inside the card. - Use designated CardRef initializers in the new code. The face-up draft still previews while face-down creation is selected. --------- Co-authored-by: Lukas BrĂ¼bach <44814898+BruebachL@users.noreply.github.com> --- cockatrice/CMakeLists.txt | 1 + .../board/abstract_card_item.cpp | 8 +- .../game_graphics/board/abstract_card_item.h | 7 + .../dialogs/dlg_create_token.cpp | 84 ++++++--- .../game_graphics/dialogs/dlg_create_token.h | 6 +- .../dialogs/token_preview_widget.cpp | 159 ++++++++++++++++++ .../dialogs/token_preview_widget.h | 25 +++ .../game/player/player_actions.cpp | 2 +- tests/game/player_actions_test.cpp | 62 +++++++ 9 files changed, 323 insertions(+), 31 deletions(-) create mode 100644 cockatrice/src/game_graphics/dialogs/token_preview_widget.cpp create mode 100644 cockatrice/src/game_graphics/dialogs/token_preview_widget.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 27ded5621..e9949551a 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -86,6 +86,7 @@ set(cockatrice_SOURCES src/game_graphics/deckview/tabbed_deck_view_container.cpp src/game_graphics/dialogs/dlg_annotation.cpp src/game_graphics/dialogs/dlg_create_token.cpp + src/game_graphics/dialogs/token_preview_widget.cpp src/game_graphics/dialogs/dlg_move_top_cards_until.cpp src/game_graphics/dialogs/dlg_roll_dice.cpp src/game_graphics/game_scene.cpp diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index 37d639653..716a09579 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -181,6 +181,11 @@ QSizeF AbstractCardItem::getTranslatedSize(QPainter *painter) const painter->combinedTransform().map(QLineF(0, 0, 0, boundingRect().height())).length()); } +bool AbstractCardItem::shouldPaintNameOverPicture() const +{ + return SettingsCache::instance().cardsDisplay().getDisplayCardNames(); +} + void AbstractCardItem::transformPainter(QPainter *painter, const QSizeF &translatedSize, int angle) { const int MAX_FONT_SIZE = SettingsCache::instance().appearance().getMaxFontSize(); @@ -233,8 +238,7 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS painter->drawPath(shape()); } - if (translatedPixmap.isNull() || SettingsCache::instance().cardsDisplay().getDisplayCardNames() || - state->getFaceDown()) { + if (translatedPixmap.isNull() || state->getFaceDown() || shouldPaintNameOverPicture()) { painter->save(); transformPainter(painter, translatedSize, angle); painter->setPen(Qt::white); diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.h b/cockatrice/src/game_graphics/board/abstract_card_item.h index 06b9a90c7..38cf99ed1 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_item.h @@ -190,6 +190,13 @@ public: protected: void transformPainter(QPainter *painter, const QSizeF &translatedSize, int angle); + /** + * @brief Whether the card name is painted over its artwork. + * + * Follows the "Display card names on cards having a picture" setting. Views + * that already show the name elsewhere, such as the token preview, turn it off. + */ + [[nodiscard]] virtual bool shouldPaintNameOverPicture() const; void mousePressEvent(QGraphicsSceneMouseEvent *event) override; void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; QVariant itemChange(QGraphicsItem::GraphicsItemChange change, const QVariant &value) override; diff --git a/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp b/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp index 5ce050380..1516dca83 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp +++ b/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp @@ -1,9 +1,9 @@ #include "dlg_create_token.h" #include "../../client/settings/cache_settings.h" -#include "../../interface/widgets/cards/card_info_picture_widget.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_querier.h" +#include "token_preview_widget.h" #include #include @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -32,6 +33,7 @@ #include #include #include +#include #include #include @@ -41,7 +43,7 @@ class QWidget; DlgCreateToken::DlgCreateToken(const QStringList &_predefinedTokens, QWidget *parent) : QDialog(parent), predefinedTokens(_predefinedTokens) { - pic = new CardInfoPictureWidget(); + pic = new TokenPreviewWidget(); pic->setObjectName("pic"); nameLabel = new QLabel(tr("&Name:")); @@ -173,6 +175,12 @@ DlgCreateToken::DlgCreateToken(const QStringList &_predefinedTokens, QWidget *pa setWindowTitle(tr("Create token")); + connect(nameEdit, &QLineEdit::textChanged, this, &DlgCreateToken::updatePreview); + connect(colorEdit, qOverload(&QComboBox::currentIndexChanged), this, &DlgCreateToken::updatePreview); + connect(ptEdit, &QLineEdit::textChanged, this, &DlgCreateToken::updatePreview); + connect(annotationEdit, &QLineEdit::textChanged, this, &DlgCreateToken::updatePreview); + updatePreview(); + resize(600, 500); restoreGeometry(SettingsCache::instance().layouts().getTokenDialogGeometry()); } @@ -185,17 +193,14 @@ void DlgCreateToken::closeEvent(QCloseEvent *event) void DlgCreateToken::faceDownCheckBoxToggled(bool checked) { + // Keep the face-up draft visible while choosing what to create. Only the + // outgoing command applies face-down color/P/T restrictions. + colorEdit->setEnabled(!checked); + ptEdit->setEnabled(!checked); if (checked) { - colorEdit->setCurrentIndex(6); - colorEdit->setEnabled(false); - ptEdit->clear(); ptEdit->clearFocus(); - ptEdit->setEnabled(false); - } else { - colorEdit->setEnabled(true); - ptEdit->setEnabled(true); - annotationEdit->setEnabled(true); } + updatePreview(); } void DlgCreateToken::tokenSelectionChanged(const QModelIndex ¤t, const QModelIndex & /*previous*/) @@ -204,11 +209,14 @@ void DlgCreateToken::tokenSelectionChanged(const QModelIndex ¤t, const QMo CardInfoPtr cardInfo; - if (current.row() >= 0) { + if (realIndex.isValid()) { cardInfo = cardDatabaseModel->getCard(realIndex.row()); } if (cardInfo) { + const QSignalBlocker colorBlocker(colorEdit); + const QSignalBlocker ptBlocker(ptEdit); + const QSignalBlocker annotationBlocker(annotationEdit); updateSearchFieldWithoutUpdatingFilter(cardInfo->getName()); const QChar cardColor = cardInfo->getColorChar(); colorEdit->setCurrentIndex(colorEdit->findData(cardColor, Qt::UserRole, Qt::MatchFixedString)); @@ -216,21 +224,22 @@ void DlgCreateToken::tokenSelectionChanged(const QModelIndex ¤t, const QMo if (SettingsCache::instance().userInterface().getAnnotateTokens()) { annotationEdit->setText(cardInfo->getText()); } - } else { - nameEdit->setText(""); - colorEdit->setCurrentIndex(colorEdit->findData(QString(), Qt::UserRole, Qt::MatchFixedString)); - ptEdit->setText(""); - annotationEdit->setText(""); } - pic->setCard(CardDatabaseManager::query()->getPreferredCard(cardInfo)); + // Filtering, changing the deck/all scope, or reloading the database can + // invalidate the current row. None of these actions discard the token draft. + updatePreview(); } void DlgCreateToken::updateSearchFieldWithoutUpdatingFilter(const QString &newValue) const { - nameEdit->blockSignals(true); + const QSignalBlocker blocker(nameEdit); nameEdit->setText(newValue); - nameEdit->blockSignals(false); +} + +void DlgCreateToken::updatePreview() +{ + pic->setToken(tokenDraft()); } void DlgCreateToken::updateSearch(const QString &search) @@ -264,13 +273,36 @@ void DlgCreateToken::actReject() reject(); } +TokenInfo DlgCreateToken::tokenDraft() const +{ + TokenInfo token = {.name = nameEdit->text(), + .color = colorEdit->itemData(colorEdit->currentIndex()).toString(), + .pt = ptEdit->text(), + .annotation = annotationEdit->text(), + .destroy = destroyCheckBox->isChecked(), + .faceDown = faceDownCheckBox->isChecked(), + .providerId = + SettingsCache::instance().cardOverrides().getCardPreferenceOverride(nameEdit->text())}; + + // Match the normalization applied when the player creates the token, so + // manually typed aliases and the default P/T preview the actual result. + const ExactCard correctedCard = + CardDatabaseManager::query()->guessCard(CardRef{.name = token.name, .providerId = token.providerId}); + if (correctedCard) { + token.name = correctedCard.getName(); + if (token.pt.isEmpty()) { + token.pt = correctedCard.getInfo().getPowTough(); + } + } + return token; +} + TokenInfo DlgCreateToken::getTokenInfo() const { - return {.name = nameEdit->text(), - .color = colorEdit->itemData(colorEdit->currentIndex()).toString(), - .pt = ptEdit->text(), - .annotation = annotationEdit->text(), - .destroy = destroyCheckBox->isChecked(), - .faceDown = faceDownCheckBox->isChecked(), - .providerId = SettingsCache::instance().cardOverrides().getCardPreferenceOverride(nameEdit->text())}; + TokenInfo token = tokenDraft(); + if (token.faceDown) { + token.color = QString(QChar()); + token.pt.clear(); + } + return token; } diff --git a/cockatrice/src/game_graphics/dialogs/dlg_create_token.h b/cockatrice/src/game_graphics/dialogs/dlg_create_token.h index 323e61829..c39928164 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_create_token.h +++ b/cockatrice/src/game_graphics/dialogs/dlg_create_token.h @@ -22,7 +22,7 @@ class QRadioButton; class QTreeView; class CardDatabaseModel; class TokenDisplayModel; -class CardInfoPictureWidget; +class TokenPreviewWidget; class QModelIndex; class QWidget; @@ -39,6 +39,7 @@ private slots: void faceDownCheckBoxToggled(bool checked); void tokenSelectionChanged(const QModelIndex ¤t, const QModelIndex &previous); void updateSearch(const QString &search); + void updatePreview(); void actChooseTokenFromAll(bool checked); void actChooseTokenFromDeck(bool checked); void actOk(); @@ -54,9 +55,10 @@ private: QCheckBox *destroyCheckBox; QCheckBox *faceDownCheckBox; QRadioButton *chooseTokenFromAllRadioButton, *chooseTokenFromDeckRadioButton; - CardInfoPictureWidget *pic; + TokenPreviewWidget *pic; QTreeView *chooseTokenView; + [[nodiscard]] TokenInfo tokenDraft() const; void updateSearchFieldWithoutUpdatingFilter(const QString &newValue) const; }; diff --git a/cockatrice/src/game_graphics/dialogs/token_preview_widget.cpp b/cockatrice/src/game_graphics/dialogs/token_preview_widget.cpp new file mode 100644 index 000000000..a4d869ca2 --- /dev/null +++ b/cockatrice/src/game_graphics/dialogs/token_preview_widget.cpp @@ -0,0 +1,159 @@ +#include "token_preview_widget.h" + +#include "../../interface/theme_manager.h" +#include "../board/abstract_card_item.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// P/T pill, matching the #7700 hover panel. Its sizes are given for a 270 px picture +// (CardHoverWidget::CARD_IMAGE_WIDTH) and scale with the preview. +constexpr qreal PILL_REFERENCE_WIDTH = 270.0; +constexpr qreal PILL_MIN_WIDTH = 48.0; +constexpr qreal PILL_MIN_HEIGHT = 22.0; +constexpr qreal PILL_TEXT_PADDING = 4.0; +constexpr qreal PILL_CORNER_RADIUS = 4.0; +constexpr qreal PILL_FONT_PIXELS = 12.0; +constexpr qreal PILL_ANCHOR_X = 0.92; +constexpr qreal PILL_ANCHOR_Y = 0.94; +constexpr int PILL_SCRIM_ALPHA = 180; +} // namespace + +class TokenPreviewCardItem : public AbstractCardItem +{ +public: + TokenPreviewCardItem() + { + setFlag(ItemIsSelectable, false); + setAcceptedMouseButtons(Qt::NoButton); + setAcceptHoverEvents(false); + setCursor(Qt::ArrowCursor); + connect(state, &CardState::stateChanged, this, [this] { update(); }); + } + + void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override + { + // Show what the user is creating even when it will enter face down. + // Reuse the board's artwork, custom-token background and name rendering. + AbstractCardItem::paint(painter, option, widget); + + const QSizeF translatedSize = getTranslatedSize(painter); + if (translatedSize.isEmpty()) { + return; + } + const qreal scale = translatedSize.width() / boundingRect().width(); + + painter->save(); + transformPainter(painter, translatedSize, 0); + + if (!state->getAnnotation().isEmpty()) { + painter->setPen(Qt::white); + painter->setBackground(Qt::black); + painter->setBackgroundMode(Qt::OpaqueMode); + const QRectF textRect(4 * scale, 4 * scale, translatedSize.width() - 8 * scale, + translatedSize.height() - 8 * scale); + painter->drawText(textRect, Qt::AlignCenter | Qt::TextWrapAnywhere, state->getAnnotation()); + } + + // The card picture already shows the printed P/T, so only a different value + // needs the pill. A custom token with no picture also has no printed value. + const QString pt = state->getPT(); + if (!pt.isEmpty() && pt != getCardInfo().getPowTough()) { + paintPtPill(painter, translatedSize, pt); + } + + painter->restore(); + } + +protected: + // The artwork carries the name; the dialog's own fields already show it. + [[nodiscard]] bool shouldPaintNameOverPicture() const override + { + return false; + } + +private: + void paintPtPill(QPainter *painter, const QSizeF &translatedSize, const QString &pt) const + { + const qreal ratio = translatedSize.width() / PILL_REFERENCE_WIDTH; + + QFont font = themeManager->appFont(AppFont::CardText); + font.setBold(true); + font.setPixelSize(std::max(1, qRound(PILL_FONT_PIXELS * ratio))); + + const QFontMetricsF metrics(font); + const QSizeF pillSize( + qMax(PILL_MIN_WIDTH * ratio, metrics.horizontalAdvance(pt) + 2 * PILL_TEXT_PADDING * ratio), + qMax(PILL_MIN_HEIGHT * ratio, metrics.height())); + const QPointF anchor(translatedSize.width() * PILL_ANCHOR_X, translatedSize.height() * PILL_ANCHOR_Y); + // Centred on the card's printed P/T box, then kept inside the card. + const QRectF pill(qBound(0.0, anchor.x() - pillSize.width() / 2, translatedSize.width() - pillSize.width()), + qBound(0.0, anchor.y() - pillSize.height() / 2, translatedSize.height() - pillSize.height()), + pillSize.width(), pillSize.height()); + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setBackgroundMode(Qt::TransparentMode); + painter->setPen(Qt::NoPen); + painter->setBrush(QColor(0, 0, 0, PILL_SCRIM_ALPHA)); + const qreal radius = PILL_CORNER_RADIUS * ratio; + painter->drawRoundedRect(pill, radius, radius); + painter->setFont(font); + painter->setPen(Qt::white); + painter->drawText(pill, Qt::AlignCenter, pt); + painter->restore(); + } +}; + +TokenPreviewWidget::TokenPreviewWidget(QWidget *parent) : QGraphicsView(parent), card(new TokenPreviewCardItem) +{ + auto *previewScene = new QGraphicsScene(this); + previewScene->addItem(card); + previewScene->setSceneRect(card->boundingRect()); + setScene(previewScene); + setInteractive(false); + setFocusPolicy(Qt::NoFocus); + setFrameShape(QFrame::NoFrame); + viewport()->setBackgroundRole(QPalette::Window); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setMinimumSize(150, 200); + setAccessibleName(tr("Token preview")); +} + +void TokenPreviewWidget::setToken(const TokenInfo &token) +{ + card->setCardRef(CardRef{.name = token.name, .providerId = token.providerId}); + card->setColor(token.color); + card->setFaceDown(false); + card->getState()->setPT(token.pt); + card->getState()->setAnnotation(token.annotation); + setToolTip(token.annotation); + QString description = token.name + " " + token.pt + " " + token.annotation; + if (token.faceDown) { + description += " " + tr("Will be created face down."); + } + setAccessibleDescription(description); +} + +QSize TokenPreviewWidget::sizeHint() const +{ + return {200, 280}; +} + +void TokenPreviewWidget::resizeEvent(QResizeEvent *event) +{ + QGraphicsView::resizeEvent(event); + fitInView(sceneRect(), Qt::KeepAspectRatio); +} diff --git a/cockatrice/src/game_graphics/dialogs/token_preview_widget.h b/cockatrice/src/game_graphics/dialogs/token_preview_widget.h new file mode 100644 index 000000000..c3ea94777 --- /dev/null +++ b/cockatrice/src/game_graphics/dialogs/token_preview_widget.h @@ -0,0 +1,25 @@ +#ifndef COCKATRICE_TOKEN_PREVIEW_WIDGET_H +#define COCKATRICE_TOKEN_PREVIEW_WIDGET_H + +#include +#include + +class TokenPreviewCardItem; + +/** A noninteractive preview of the token that the dialog will create. */ +class TokenPreviewWidget : public QGraphicsView +{ + Q_OBJECT +public: + explicit TokenPreviewWidget(QWidget *parent = nullptr); + void setToken(const TokenInfo &token); + QSize sizeHint() const override; + +protected: + void resizeEvent(QResizeEvent *event) override; + +private: + TokenPreviewCardItem *card; +}; + +#endif diff --git a/libcockatrice_game/libcockatrice/game/player/player_actions.cpp b/libcockatrice_game/libcockatrice/game/player/player_actions.cpp index 1e854cbd4..27d01820a 100644 --- a/libcockatrice_game/libcockatrice/game/player/player_actions.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_actions.cpp @@ -951,7 +951,7 @@ void PlayerActions::actCreateToken(TokenInfo tokenToCreate) lastTokenInfo.name = correctedCard.getName(); int tableRow = lastTokenInfo.faceDown ? 2 : correctedCard.getInfo().getUiAttributes().tableRow; lastTokenTableRow = TableZoneLogic::tableRowToGridY(tableRow); - if (lastTokenInfo.pt.isEmpty()) { + if (!lastTokenInfo.faceDown && lastTokenInfo.pt.isEmpty()) { lastTokenInfo.pt = correctedCard.getInfo().getPowTough(); } } diff --git a/tests/game/player_actions_test.cpp b/tests/game/player_actions_test.cpp index 11ae7338d..e483eb4e6 100644 --- a/tests/game/player_actions_test.cpp +++ b/tests/game/player_actions_test.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -117,3 +118,64 @@ TEST(PlayerActionsTest, PlayCiptCreatureSendsTapped) EXPECT_TRUE(card.has_tapped()); EXPECT_TRUE(card.tapped()); } + +// These three #7737 command cases use the current shared game harness. +TEST(TokenCommandTest, FaceDownKnownTokenDoesNotRestoreDatabasePowerToughness) +{ + GameHarness harness; + harness.client()->sent.clear(); + TokenInfo token; + token.name = QStringLiteral("Kitten"); + token.color = QString(QChar()); + token.faceDown = true; + harness.player()->getPlayerActions()->actCreateToken(token); + ASSERT_EQ(harness.client()->sent.size(), 1); + ASSERT_EQ(harness.lastCommand().game_command_size(), 1); + const auto &gameCommand = harness.lastCommand().game_command(0); + ASSERT_TRUE(gameCommand.HasExtension(Command_CreateToken::ext)); + const auto &command = gameCommand.GetExtension(Command_CreateToken::ext); + EXPECT_EQ(command.card_name(), "Kitten"); + EXPECT_TRUE(command.face_down()); + EXPECT_TRUE(command.pt().empty()); + harness.player()->getPlayerActions()->actCreateAnotherToken(); + ASSERT_EQ(harness.client()->sent.size(), 2); + const auto &repeat = harness.lastCommand().game_command(0).GetExtension(Command_CreateToken::ext); + EXPECT_TRUE(repeat.face_down()); + EXPECT_TRUE(repeat.pt().empty()); +} + +TEST(TokenCommandTest, FaceUpKnownAliasKeepsCanonicalNameAndDefaultPowerToughness) +{ + GameHarness harness; + harness.client()->sent.clear(); + TokenInfo token; + token.name = QStringLiteral("kitten"); + harness.player()->getPlayerActions()->actCreateToken(token); + ASSERT_EQ(harness.client()->sent.size(), 1); + ASSERT_EQ(harness.lastCommand().game_command_size(), 1); + const auto &gameCommand = harness.lastCommand().game_command(0); + ASSERT_TRUE(gameCommand.HasExtension(Command_CreateToken::ext)); + const auto &command = gameCommand.GetExtension(Command_CreateToken::ext); + EXPECT_EQ(command.card_name(), "Kitten"); + EXPECT_FALSE(command.face_down()); + EXPECT_EQ(command.pt(), "1/1"); +} + +TEST(TokenCommandTest, FaceUpKnownTokenPreservesExplicitPowerToughness) +{ + GameHarness harness; + harness.client()->sent.clear(); + TokenInfo token; + token.name = QStringLiteral("Kitten"); + token.pt = QStringLiteral("7/9"); + token.annotation = QStringLiteral("Flying"); + harness.player()->getPlayerActions()->actCreateToken(token); + ASSERT_EQ(harness.client()->sent.size(), 1); + ASSERT_EQ(harness.lastCommand().game_command_size(), 1); + const auto &gameCommand = harness.lastCommand().game_command(0); + ASSERT_TRUE(gameCommand.HasExtension(Command_CreateToken::ext)); + const auto &command = gameCommand.GetExtension(Command_CreateToken::ext); + EXPECT_FALSE(command.face_down()); + EXPECT_EQ(command.pt(), "7/9"); + EXPECT_EQ(command.annotation(), "Flying"); +}