#include "card_hover_widget.h" #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/card_counter_settings.h" #include "../../../game_graphics/board/card_item.h" #include "../../../game_graphics/game_scene.h" #include "../../../game_graphics/player/player_graphics_item.h" #include "../../pixel_map_generator.h" #include "card_info_picture_widget.h" #include "card_info_text_widget.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include static constexpr int CARD_IMAGE_WIDTH = 270; static constexpr int TAB_MIN_WIDTH = 250; static constexpr int TAB_MAX_WIDTH = 300; static constexpr int TAB_MIN_HEIGHT = 360; static constexpr int NAV_BUTTON_WIDTH = 78; static constexpr int NAV_ICON_SIZE = 24; static constexpr int POPUP_GAP = 6; static constexpr int FADE_DURATION_MS = 140; static constexpr int REFRESH_DEBOUNCE_MS = 30; static constexpr int OVERLAY_BTN_SIZE = 24; static constexpr int OVERLAY_CORNER_RADIUS = 4; enum HoverPanelPage { PAGE_INFO, PAGE_MAIN, PAGE_MOVE, PAGE_COUNTER, PAGE_TOKEN }; CardHoverWidget::CardHoverWidget(QWidget *parent) : QFrame(parent), currentCard(nullptr), currentPlayer(nullptr) { setObjectName("cardHoverWidget"); setFrameShape(QFrame::StyledPanel); setWindowFlags(Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::ToolTip); setAttribute(Qt::WA_DeleteOnClose, false); setAttribute(Qt::WA_ShowWithoutActivating, true); setMouseTracking(true); setContentsMargins(0, 0, 0, 0); applyThemeStyle(); setWindowOpacity(0.0); fadeIn = new QPropertyAnimation(this, "windowOpacity", this); fadeIn->setDuration(FADE_DURATION_MS); fadeIn->setStartValue(0.0); fadeIn->setEndValue(1.0); fadeIn->setEasingCurve(QEasingCurve::OutCubic); fadeOut = new QPropertyAnimation(this, "windowOpacity", this); fadeOut->setDuration(FADE_DURATION_MS); fadeOut->setStartValue(1.0); fadeOut->setEndValue(0.0); fadeOut->setEasingCurve(QEasingCurve::OutCubic); connect(fadeOut, &QPropertyAnimation::finished, this, &QWidget::hide); refreshTimer = new QTimer(this); refreshTimer->setSingleShot(true); refreshTimer->setInterval(REFRESH_DEBOUNCE_MS); connect(refreshTimer, &QTimer::timeout, this, &CardHoverWidget::onCardChanged); createImageArea(); createContentStack(); createNavBar(); mainLayout = new QHBoxLayout(this); mainLayout->setContentsMargins(8, 8, 8, 8); mainLayout->setSpacing(8); mainLayout->addWidget(imageContainer, 0); mainLayout->addWidget(sideBar, 0); mainLayout->addWidget(contentStack, 1); setLayout(mainLayout); showPage(PAGE_INFO); } void CardHoverWidget::applyThemeStyle() { const QPalette &pal = QApplication::palette(); const QString window = pal.color(QPalette::Window).name(); const QString windowText = pal.color(QPalette::WindowText).name(); const QString base = pal.color(QPalette::Base).name(); const QString text = pal.color(QPalette::Text).name(); const QString mid = pal.color(QPalette::Mid).name(); const QString highlight = pal.color(QPalette::Highlight).name(); const QString highlightedText = pal.color(QPalette::HighlightedText).name(); const QString disabled = pal.color(QPalette::PlaceholderText).name(); // Opaque theme colors throughout — translucency proved too hard to read. // The dark scrims over the card artwork itself are kept, they sit on the image, not the panel. // Guard: setStyleSheet() itself emits PaletteChange, which would otherwise // re-enter applyThemeStyle() via changeEvent() forever (freeze, then stack overflow). QString sheet = QStringLiteral("QFrame#cardHoverWidget {" " background-color: %1;" " border: 1px solid %2;" " border-radius: 10px;" "}" "QScrollArea { background: transparent; border: none; }" "QToolButton#cardHoverNavBtn {" " background: transparent; color: %4;" " border: 1px solid transparent; border-radius: 8px;" " padding: 7px 4px; font-size: 11px;" "}" "QToolButton#cardHoverNavBtn:hover {" " background: %3; border-color: %2;" "}" "QToolButton#cardHoverNavBtn:checked {" " background: %5; color: %6; border-color: %5;" "}" "QLabel#cardHoverName {" " color: %8; font-weight: bold; font-size: 12px;" "}" "QPushButton#cardHoverActionBtn {" " background: %3; color: %4;" " border: 1px solid %2; border-radius: 6px;" " padding: 5px 10px; font-size: 12px; text-align: center;" "}" "QPushButton#cardHoverActionBtn:hover {" " background: %5; color: %6; border-color: %5;" "}" "QPushButton#cardHoverActionBtn:pressed {" " background: %5; color: %6;" "}" "QPushButton#cardHoverActionBtn:checked { background: %5; color: %6; }" "QPushButton#cardHoverActionBtn:disabled { color: %7; }" "QToolButton#cardHoverGridBtn {" " background: %3; color: %4;" " border: 1px solid %2; border-radius: 6px;" " padding: 6px 4px; font-size: 11px;" "}" "QToolButton#cardHoverGridBtn:hover {" " background: %5; color: %6; border-color: %5;" "}" "QToolButton#cardHoverGridBtn:pressed {" " background: %5; color: %6;" "}" "QToolButton#cardHoverGridBtn:checked { background: %5; color: %6; }" "QToolButton#cardHoverGridBtn:disabled { color: %7; }" "QLabel#cardHoverSectionHeader {" " background: %2; color: %8;" " border-radius: 4px; padding: 3px 6px;" " font-weight: bold; font-size: 11px;" "}" "QLabel#cardHoverNote { color: %7; font-style: italic; font-size: 11px; }" "QLineEdit#cardHoverNameEdit {" " color: %4; background: %3; border: 1px solid %2;" " border-radius: 3px; padding: 2px;" " font-weight: bold; font-size: 13px;" "}") .arg(window, mid, base, text, highlight, highlightedText, disabled, windowText); if (sheet == styleSheet()) { return; } setStyleSheet(sheet); } // ---------- Overlay styling (dark scrims over the card artwork) ---------- static QString overlayBgStyle() { return QStringLiteral("background-color: rgba(0, 0, 0, 180);" "border-radius: %1px;") .arg(OVERLAY_CORNER_RADIUS); } // ---------- Image area with overlaid controls ---------- void CardHoverWidget::createImageArea() { imageContainer = new QWidget(this); imageContainer->setObjectName("cardHoverImageContainer"); imageContainer->setMouseTracking(true); imageContainer->setFixedWidth(CARD_IMAGE_WIDTH); pic = new CardInfoPictureWidget(imageContainer); pic->setObjectName("cardHoverPic"); pic->setMouseTracking(true); pic->setHoverToZoomEnabled(false); pic->setScaleFactor(135); // full-picture expanded size (~270px wide) auto *picLayout = new QVBoxLayout(imageContainer); picLayout->setContentsMargins(0, 0, 0, 0); picLayout->setSpacing(0); picLayout->addWidget(pic); imageContainer->setLayout(picLayout); createNameBarOverlay(); createPTOverlay(); pic->installEventFilter(this); } // ---------- Name bar overlay (top of card) ---------- void CardHoverWidget::createNameBarOverlay() { nameBarOverlay = new QWidget(imageContainer); nameBarOverlay->setObjectName("cardHoverNameBar"); nameBarOverlay->setMouseTracking(true); nameBarOverlay->setStyleSheet(overlayBgStyle()); nameLabel = new QLabel(nameBarOverlay); nameLabel->setObjectName("cardHoverNameLabel"); nameLabel->setAlignment(Qt::AlignCenter); nameLabel->setStyleSheet( "color: white; font-weight: bold; font-size: 13px; background: transparent; padding: 2px;"); nameLabel->setWordWrap(false); nameEdit = new QLineEdit(nameBarOverlay); nameEdit->setObjectName("cardHoverNameEdit"); nameEdit->setAlignment(Qt::AlignCenter); nameEdit->setFixedHeight(22); nameEdit->setPlaceholderText(tr("Set annotation...")); nameEdit->setVisible(false); connect(nameEdit, &QLineEdit::editingFinished, this, [this]() { if (!nameEdit->isVisible()) { return; } if (currentCard && currentCard->getOwner()) { auto *actions = currentCard->getOwner()->getPlayerActions(); if (actions) { QList cardList{currentCard->getState()}; actions->actSetAnnotation(cardList, nameEdit->text()); } } nameEdit->setVisible(false); nameLabel->setVisible(true); refreshDisplay(); }); annotationLabel = new QLabel(nameBarOverlay); annotationLabel->setObjectName("cardHoverAnnotationLabel"); annotationLabel->setAlignment(Qt::AlignCenter); annotationLabel->setStyleSheet( "color: #dddddd; font-style: italic; font-size: 11px; background: transparent; padding: 1px;"); annotationLabel->setWordWrap(false); annotationLabel->setVisible(false); auto *nameLayout = new QVBoxLayout(nameBarOverlay); nameLayout->setContentsMargins(6, 3, 6, 3); nameLayout->setSpacing(1); nameLayout->addWidget(nameLabel); nameLayout->addWidget(nameEdit); nameLayout->addWidget(annotationLabel); nameBarOverlay->setLayout(nameLayout); nameLabel->installEventFilter(this); } // ---------- P/T overlay (bottom-right of card) ---------- void CardHoverWidget::createPTOverlay() { ptOverlay = new QWidget(imageContainer); ptOverlay->setObjectName("cardHoverPTOverlay"); ptOverlay->setMouseTracking(true); ptOverlay->setStyleSheet(overlayBgStyle()); auto getActions = [this]() -> PlayerActions * { if (currentCard && currentCard->getOwner()) { return currentCard->getOwner()->getPlayerActions(); } return nullptr; }; auto cardList = [this]() -> QList { return currentCard && currentCard->getState() ? QList{currentCard->getState()} : QList(); }; ptPowerLabel = new QLabel(ptOverlay); ptPowerLabel->setObjectName("cardHoverPTPowerValue"); ptPowerLabel->setAlignment(Qt::AlignCenter); ptPowerLabel->setStyleSheet( "color: white; font-weight: bold; font-size: 12px; background: transparent; padding: 0 2px;"); ptPowerLabel->setMinimumWidth(28); ptToughLabel = new QLabel(ptOverlay); ptToughLabel->setObjectName("cardHoverPTToughValue"); ptToughLabel->setAlignment(Qt::AlignCenter); ptToughLabel->setStyleSheet( "color: white; font-weight: bold; font-size: 12px; background: transparent; padding: 0 2px;"); ptToughLabel->setMinimumWidth(28); btnDecP = createOverlayButton("-P", tr("Decrease power"), [getActions, cardList]() { if (auto *a = getActions()) { a->actDecP(cardList()); } }); btnIncP = createOverlayButton("+P", tr("Increase power"), [getActions, cardList]() { if (auto *a = getActions()) { a->actIncP(cardList()); } }); btnDecT = createOverlayButton("-T", tr("Decrease toughness"), [getActions, cardList]() { if (auto *a = getActions()) { a->actDecT(cardList()); } }); btnIncT = createOverlayButton("+T", tr("Increase toughness"), [getActions, cardList]() { if (auto *a = getActions()) { a->actIncT(cardList()); } }); btnSetPT = createOverlayButton("SET", tr("Set power/toughness..."), [getActions, cardList]() { if (auto *a = getActions()) { a->actRequestSetPTDialog(cardList()); } }); btnResetPT = createOverlayButton("RST", tr("Reset power/toughness"), [getActions, cardList]() { if (auto *a = getActions()) { a->actResetPT(cardList()); } }); auto *powerRow = new QHBoxLayout(); powerRow->setSpacing(2); powerRow->addWidget(btnDecP); powerRow->addWidget(ptPowerLabel); powerRow->addWidget(btnIncP); auto *toughRow = new QHBoxLayout(); toughRow->setSpacing(2); toughRow->addWidget(btnDecT); toughRow->addWidget(ptToughLabel); toughRow->addWidget(btnIncT); auto *btnRow = new QHBoxLayout(); btnRow->setSpacing(2); btnRow->addWidget(btnSetPT); btnRow->addWidget(btnResetPT); auto *ptLayout = new QVBoxLayout(ptOverlay); ptLayout->setContentsMargins(4, 3, 4, 3); ptLayout->setSpacing(2); ptLayout->addLayout(powerRow); ptLayout->addLayout(toughRow); ptLayout->addLayout(btnRow); ptOverlay->setLayout(ptLayout); } // ---------- Nav strip: vertical icon buttons with horizontal text ---------- void CardHoverWidget::createNavBar() { sideBar = new QWidget(this); sideBar->setObjectName("cardHoverSideBar"); navNameLabel = new QLabel(sideBar); navNameLabel->setObjectName("cardHoverName"); navNameLabel->setAlignment(Qt::AlignCenter); navNameLabel->setWordWrap(true); navGroup = new QButtonGroup(sideBar); navGroup->setExclusive(true); navInfoBtn = createNavButton(tr("Info"), QStringLiteral("icons/info"), PAGE_INFO); navMainBtn = createNavButton(tr("Main"), QStringLiteral("icons/cogwheel"), PAGE_MAIN); navMoveBtn = createNavButton(tr("Move"), QStringLiteral("icons/swap"), PAGE_MOVE); navCounterBtn = createNavButton(tr("Counters"), QStringLiteral("icons/increment"), PAGE_COUNTER); navTokenBtn = createNavButton(tr("Tokens"), QStringLiteral("icons/dragon"), PAGE_TOKEN); connect(navGroup, &QButtonGroup::idClicked, this, [this](int id) { contentStack->setCurrentIndex(id); }); auto *barLayout = new QVBoxLayout(sideBar); barLayout->setContentsMargins(0, 0, 0, 0); barLayout->setSpacing(4); barLayout->setAlignment(Qt::AlignTop); barLayout->addWidget(navNameLabel); barLayout->addWidget(navInfoBtn); barLayout->addWidget(navMainBtn); barLayout->addWidget(navMoveBtn); barLayout->addWidget(navCounterBtn); barLayout->addWidget(navTokenBtn); barLayout->addStretch(1); sideBar->setLayout(barLayout); } QToolButton *CardHoverWidget::createNavButton(const QString &title, const QString &iconName, int pageIndex) { auto *btn = new QToolButton(sideBar); btn->setObjectName("cardHoverNavBtn"); btn->setText(title); btn->setToolTip(title); btn->setIcon(QIcon(themePixmap(iconName))); btn->setIconSize(QSize(NAV_ICON_SIZE, NAV_ICON_SIZE)); btn->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); btn->setCheckable(true); btn->setAutoExclusive(true); btn->setMinimumWidth(NAV_BUTTON_WIDTH); navGroup->addButton(btn, pageIndex); return btn; } void CardHoverWidget::showPage(int index) { contentStack->setCurrentIndex(index); if (auto *btn = navGroup->button(index)) { btn->setChecked(true); } } // ---------- Content pages (no nested tab bars) ---------- void CardHoverWidget::createContentStack() { contentStack = new QStackedWidget(this); contentStack->setMinimumWidth(TAB_MIN_WIDTH); contentStack->setMaximumWidth(TAB_MAX_WIDTH); contentStack->setMinimumHeight(TAB_MIN_HEIGHT); infoPage = new QWidget(contentStack); text = new CardInfoTextWidget(infoPage); text->setObjectName("cardHoverText"); connect(text, &CardInfoTextWidget::linkActivated, this, [this](const QString &cardName) { ExactCard card = CardDatabaseManager::query()->guessCard({cardName}); if (!card.isEmpty()) { text->setCard(card); } }); auto *infoScroll = new QScrollArea(infoPage); infoScroll->setWidget(text); infoScroll->setWidgetResizable(true); infoScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); infoScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); infoScroll->setFrameShape(QFrame::NoFrame); infoPageLayout = new QVBoxLayout(infoPage); infoPageLayout->setContentsMargins(4, 4, 4, 4); infoPageLayout->addWidget(infoScroll, 1); infoPage->setLayout(infoPageLayout); mainPage = makeStackPage(mainPageLayout); movePage = makeStackPage(movePageLayout); counterPage = makeStackPage(counterPageLayout); tokenPage = makeStackPage(tokenPageLayout); contentStack->addWidget(infoPage); contentStack->addWidget(mainPage); contentStack->addWidget(movePage); contentStack->addWidget(counterPage); contentStack->addWidget(tokenPage); } QWidget *CardHoverWidget::makeStackPage(QVBoxLayout *&layout) { auto *page = new QWidget(contentStack); auto *inner = new QWidget(page); layout = new QVBoxLayout(inner); layout->setContentsMargins(4, 4, 4, 4); layout->setSpacing(4); layout->setAlignment(Qt::AlignTop); inner->setLayout(layout); auto *scroll = new QScrollArea(page); scroll->setWidget(inner); scroll->setWidgetResizable(true); scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); scroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); scroll->setFrameShape(QFrame::NoFrame); auto *outer = new QVBoxLayout(page); outer->setContentsMargins(0, 0, 0, 0); outer->addWidget(scroll, 1); page->setLayout(outer); return page; } QPushButton *CardHoverWidget::createSmallButton(const QString &text, const std::function &slot, QWidget *parent) { return createSmallButton(text, QString(), slot, parent); } // Packs icon-over-text tool buttons into a uniform grid so action pages use the full width. class ButtonGrid { public: explicit ButtonGrid(QVBoxLayout *layout, int columns = 2) : columns(columns) { grid = new QGridLayout(); grid->setContentsMargins(0, 0, 0, 0); grid->setSpacing(4); layout->addLayout(grid); } void addSection(QWidget *header) { if (col != 0) { ++row; col = 0; } grid->addWidget(header, row++, 0, 1, columns); } void addButton(QWidget *button) { grid->addWidget(button, row, col++); if (col >= columns) { col = 0; ++row; } } private: QGridLayout *grid; int columns; int row = 0; int col = 0; }; QToolButton *CardHoverWidget::createGridButton(const QString &text, const QString &iconName, const std::function &slot, QWidget *parent, const QString &tooltip) { auto *btn = new QToolButton(parent); btn->setObjectName("cardHoverGridBtn"); btn->setText(text); // Short cell labels can clip; the full text is always one hover away. btn->setToolTip(tooltip.isEmpty() ? text : tooltip); btn->setIcon(QIcon(themePixmap(iconName))); btn->setIconSize(QSize(22, 22)); btn->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); btn->setMinimumHeight(58); btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); connect(btn, &QToolButton::clicked, this, [slot]() { slot(); }); return btn; } QPushButton *CardHoverWidget::createSmallButton(const QString &text, const QString &tooltip, const std::function &slot, QWidget *parent) { auto *btn = new QPushButton(text, parent ? parent : contentStack); btn->setObjectName("cardHoverActionBtn"); btn->setMinimumHeight(30); if (!tooltip.isEmpty()) { btn->setToolTip(tooltip); } connect(btn, &QPushButton::clicked, this, [slot]() { slot(); }); return btn; } QPushButton * CardHoverWidget::createOverlayButton(const QString &text, const QString &tooltip, const std::function &slot) { const QPalette &pal = QApplication::palette(); QColor bg = pal.color(QPalette::Dark); bg.setAlpha(180); QColor hoverBg = pal.color(QPalette::Mid); hoverBg.setAlpha(200); QColor pressBg = pal.color(QPalette::Shadow); pressBg.setAlpha(200); QColor fg = pal.color(QPalette::ButtonText); auto *btn = new QPushButton(text, imageContainer); btn->setObjectName("cardHoverOverlayBtn"); btn->setToolTip(tooltip); btn->setFixedSize(OVERLAY_BTN_SIZE + 8, OVERLAY_BTN_SIZE); btn->setFlat(true); btn->setStyleSheet(QStringLiteral("QPushButton {" " color: %1; font-size: 10px; font-weight: bold;" " background: %2; border: none;" " border-radius: 3px;" "}" "QPushButton:hover { background: %3; }" "QPushButton:pressed { background: %4; }") .arg(fg.name(), bg.name(), hoverBg.name(), pressBg.name())); connect(btn, &QPushButton::clicked, this, [slot]() { slot(); }); return btn; } QLabel *CardHoverWidget::createSectionHeader(const QString &text, QWidget *parent) { auto *lbl = new QLabel(text, parent); lbl->setObjectName("cardHoverSectionHeader"); lbl->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); lbl->setMinimumHeight(22); return lbl; } QLabel *CardHoverWidget::createNoteLabel(const QString &text, QWidget *parent) { auto *lbl = new QLabel(text, parent); lbl->setObjectName("cardHoverNote"); lbl->setAlignment(Qt::AlignCenter); lbl->setWordWrap(true); return lbl; } // ---------- Live updates: stay in sync while the popup is open ---------- void CardHoverWidget::connectCardSignals() { if (!currentCard) { return; } if (CardState *state = currentCard->getState()) { connect(state, &CardState::stateChanged, this, &CardHoverWidget::scheduleRefresh); } connect(currentCard, &CardItem::tappedChanged, this, &CardHoverWidget::scheduleRefresh); connect(currentCard, &CardItem::faceDownChanged, this, &CardHoverWidget::scheduleRefresh); connect(currentCard, &QObject::destroyed, this, &CardHoverWidget::onCardDestroyed); } void CardHoverWidget::disconnectCardSignals() { if (currentCard) { if (CardState *state = currentCard->getState()) { disconnect(state, nullptr, this, nullptr); } disconnect(currentCard, nullptr, this, nullptr); } } void CardHoverWidget::scheduleRefresh() { if (!currentCard) { return; } if (!refreshTimer->isActive()) { refreshTimer->start(); } } void CardHoverWidget::onCardChanged() { if (!currentCard) { return; } // The underlying card may have changed identity (copy effects, flip to another face) ExactCard now = currentCard->getCard(); if (now.getCardPtr() != shownCard.getCardPtr()) { shownCard = now; pic->setCard(now); text->setCard(now); nameLabel->setText(now.getInfo().getName()); navNameLabel->setText(now.getInfo().getName()); } refreshDisplay(); rebuildActionsForZone(); positionOverlays(); } void CardHoverWidget::onCardDestroyed() { // Never touch the sender here: it is mid-teardown. Qt already auto-disconnects // its signals, so just drop our reference; clearCard() then skips sender access. currentCard = nullptr; clearCard(); hideAnimated(); } // ---------- Grouped action builders (mirror CardMenu per zone) ---------- void CardHoverWidget::rebuildActionsForZone() { clearLayout(mainPageLayout); clearLayout(movePageLayout); clearLayout(counterPageLayout); clearLayout(tokenPageLayout); if (!currentCard || !currentPlayer) { return; } auto *card = currentCard.data(); QList cardList{card->getState()}; bool canModify = currentPlayer->getLogic()->getPlayerInfo()->getLocalOrJudge(); if (auto *view = qobject_cast(card->getZone())) { if (view->getRevealZone() && !view->getWriteableRevealZone()) { canModify = false; } } QString zoneName = card->getZone() ? card->getZone()->getName() : QString(); buildMainTab(cardList, zoneName, canModify); buildMoveTab(cardList, zoneName, canModify); buildCounterTab(cardList, zoneName, canModify); buildTokenTab(card, canModify); } void CardHoverWidget::buildMainTab(const QList &cardList, const QString &zoneName, bool canModify) { auto *layout = mainPageLayout; auto *card = currentCard.data(); auto *player = currentPlayer.data(); auto *actions = player->getLogic()->getPlayerActions(); auto *gameScene = player->getGameScene(); auto invoke = [actions, cardList](CardMenuActionType type) { return [actions, cardList, type]() { actions->cardMenuAction(cardList, type); }; }; auto attach = [card]() { card->drawAttachArrow(); }; auto drawArrow = [card]() { card->drawArrow(Qt::red); }; if (!canModify) { ButtonGrid grid(layout); grid.addSection(createSectionHeader(tr("Revealed"), mainPage)); grid.addButton(createGridButton( tr("Hide"), QStringLiteral("icons/arrow_left_green"), [actions, cardList]() { actions->actHide(cardList); }, mainPage)); grid.addButton(createGridButton(tr("Clone"), QStringLiteral("icons/increment"), invoke(cmClone), mainPage)); grid.addSection(createSectionHeader(tr("Select"), mainPage)); grid.addButton(createGridButton( tr("Select All"), QStringLiteral("icons/search"), [gameScene, card]() { gameScene->selectAllInZone(card); }, mainPage)); grid.addButton(createGridButton( tr("Select Column"), QStringLiteral("icons/filter"), [gameScene, card]() { gameScene->selectSameColumn(card); }, mainPage)); layout->addStretch(1); return; } if (zoneName == ZoneNames::TABLE) { ButtonGrid grid(layout); grid.addSection(createSectionHeader(tr("Battlefield"), mainPage)); grid.addButton(createGridButton(tr("Tap / Untap"), QStringLiteral("icons/sync"), invoke(cmTap), mainPage)); auto *doesntUntap = createGridButton( tr("Skip untapping"), QStringLiteral("icons/lock"), [actions, cardList]() { actions->cardMenuAction(cardList, cmDoesntUntap); }, mainPage); doesntUntap->setCheckable(true); doesntUntap->setChecked(card->getDoesntUntap()); grid.addButton(doesntUntap); grid.addButton(createGridButton(tr("Turn Over"), QStringLiteral("icons/swap"), invoke(cmFlip), mainPage)); if (card->getFaceDown()) { grid.addButton( createGridButton(tr("Peek at card face"), QStringLiteral("icons/view"), invoke(cmPeek), mainPage)); } grid.addButton(createGridButton(tr("Clone"), QStringLiteral("icons/increment"), invoke(cmClone), mainPage)); grid.addButton(createGridButton(tr("Attach to card..."), QStringLiteral("icons/pin"), attach, mainPage)); if (card->getAttachedTo()) { grid.addButton(createGridButton( tr("Unattach"), QStringLiteral("icons/arrow_undo"), [actions, cardList]() { actions->actUnattach(cardList); }, mainPage)); } grid.addButton(createGridButton(tr("Draw arrow..."), QStringLiteral("icons/share"), drawArrow, mainPage)); grid.addButton(createGridButton( tr("Set annotation..."), QStringLiteral("icons/pencil"), [actions, cardList]() { actions->actRequestSetAnnotationDialog(cardList); }, mainPage)); grid.addButton(createGridButton( tr("Reduce life"), QStringLiteral("icons/arrow_down_green"), [actions, cardList]() { actions->actReduceLifeByPower(cardList); }, mainPage)); grid.addSection(createSectionHeader(tr("Select"), mainPage)); grid.addButton(createGridButton( tr("Select All"), QStringLiteral("icons/search"), [gameScene, card]() { gameScene->selectAllInZone(card); }, mainPage)); grid.addButton(createGridButton( tr("Select Row"), QStringLiteral("icons/filter"), [gameScene, card]() { gameScene->selectSameRow(card); }, mainPage)); } else if (!zoneName.isEmpty()) { ButtonGrid grid(layout); grid.addSection(createSectionHeader(tr("Zone"), mainPage)); grid.addButton(createGridButton( tr("Play"), QStringLiteral("icons/arrow_right_green"), [actions, cardList]() { actions->actPlay(cardList); }, mainPage)); grid.addButton(createGridButton( tr("Play Face Down"), QStringLiteral("icons/arrow_right_blue"), [actions, cardList]() { actions->actPlayFacedown(cardList); }, mainPage)); grid.addButton(createGridButton(tr("Clone"), QStringLiteral("icons/increment"), invoke(cmClone), mainPage)); if (zoneName == ZoneNames::STACK || zoneName == ZoneNames::HAND || zoneName == ZoneNames::GRAVE || zoneName == ZoneNames::EXILE) { grid.addButton(createGridButton(tr("Attach to card..."), QStringLiteral("icons/pin"), attach, mainPage)); grid.addButton(createGridButton(tr("Draw arrow..."), QStringLiteral("icons/share"), drawArrow, mainPage)); } grid.addSection(createSectionHeader(tr("Select"), mainPage)); grid.addButton(createGridButton( tr("Select All"), QStringLiteral("icons/search"), [gameScene, card]() { gameScene->selectAllInZone(card); }, mainPage)); if (qobject_cast(card->getZone())) { grid.addButton(createGridButton( tr("Select Column"), QStringLiteral("icons/filter"), [gameScene, card]() { gameScene->selectSameColumn(card); }, mainPage)); } } else { layout->addWidget(createNoteLabel(tr("No card actions here — see the Move tab."), mainPage)); } layout->addStretch(1); } void CardHoverWidget::buildMoveTab(const QList &cardList, const QString &zoneName, bool canModify) { auto *layout = movePageLayout; if (!canModify) { layout->addWidget(createNoteLabel(tr("Read-only."), movePage)); layout->addStretch(1); return; } auto *player = currentPlayer.data(); auto *actions = player->getLogic()->getPlayerActions(); auto invoke = [actions, cardList](CardMenuActionType type) { return [actions, cardList, type]() { actions->cardMenuAction(cardList, type); }; }; ButtonGrid grid(layout); grid.addSection(createSectionHeader(tr("Move to"), movePage)); grid.addButton(createGridButton(tr("Top of library"), QStringLiteral("icons/arrow_top_green"), invoke(cmMoveToTopLibrary), movePage)); grid.addButton(createGridButton( tr("X from top..."), QStringLiteral("icons/search"), [actions]() { actions->actRequestMoveCardXCardsFromTopDialog(); }, movePage, tr("X from top of library..."))); grid.addButton(createGridButton(tr("Bottom of library"), QStringLiteral("icons/arrow_bottom_green"), invoke(cmMoveToBottomLibrary), movePage)); grid.addButton(createGridButton(tr("Table"), QStringLiteral("icons/player"), invoke(cmMoveToTable), movePage)); grid.addButton( createGridButton(tr("Hand"), QStringLiteral("icons/arrow_left_green"), invoke(cmMoveToHand), movePage)); grid.addButton(createGridButton(tr("Graveyard"), QStringLiteral("icons/arrow_down_green"), invoke(cmMoveToGraveyard), movePage)); grid.addButton( createGridButton(tr("Exile"), QStringLiteral("icons/arrow_right_blue"), invoke(cmMoveToExile), movePage)); // Reveal actions live on hand / custom zones in CardMenu if (!zoneName.isEmpty() && zoneName != ZoneNames::TABLE && zoneName != ZoneNames::STACK && zoneName != ZoneNames::GRAVE && zoneName != ZoneNames::EXILE) { auto *game = player->getLogic()->getGame(); if (game && game->getPlayerManager()) { grid.addSection(createSectionHeader(tr("Reveal to..."), movePage)); grid.addButton(createGridButton( tr("All players"), QStringLiteral("icons/share"), [actions, cardList]() { auto *fake = new QAction(nullptr); fake->setData(-1); actions->actReveal(cardList, fake); fake->deleteLater(); }, movePage)); const auto players = game->getPlayerManager()->getPlayers().values(); for (auto *other : players) { if (other == player->getLogic()) { continue; } QString name = other->getPlayerInfo()->getName(); int id = other->getPlayerInfo()->getId(); grid.addButton(createGridButton( name, QStringLiteral("icons/player"), [actions, cardList, id]() { auto *fake = new QAction(nullptr); fake->setData(id); actions->actReveal(cardList, fake); fake->deleteLater(); }, movePage)); } } } layout->addStretch(1); } void CardHoverWidget::buildCounterTab(const QList &cardList, const QString &zoneName, bool canModify) { auto *layout = counterPageLayout; auto *actions = currentPlayer->getLogic()->getPlayerActions(); if (!canModify) { layout->addWidget(createNoteLabel(tr("Read-only."), counterPage)); layout->addStretch(1); return; } if (zoneName != ZoneNames::TABLE) { layout->addWidget(createNoteLabel( tr("Counters live on battlefield cards. PT and counters can be changed there."), counterPage)); layout->addStretch(1); return; } layout->addWidget(createSectionHeader(tr("Power / Toughness"), counterPage)); layout->addWidget(createSmallButton( tr("Set power and toughness..."), [actions, cardList]() { actions->actRequestSetPTDialog(cardList); }, counterPage)); layout->addWidget(createSmallButton( tr("Reset power and toughness"), [actions, cardList]() { actions->actResetPT(cardList); }, counterPage)); // One group per counter: color dot plus live value in the header, its actions in // a compact row beneath it. Rebuilt on every card change, so values never go stale. const QMap counters = currentCard ? currentCard->getCounters() : QMap{}; for (int i = 0; i < 6; ++i) { const QString counterName = SettingsCache::instance().cardCounters().displayName(i); const QString dot = QStringLiteral("\u25cf ") .arg(SettingsCache::instance().cardCounters().color(i).name()); const int value = counters.value(i, 0); layout->addWidget(createSectionHeader(dot + tr("%1 \u00d7 %2").arg(counterName).arg(value), counterPage)); auto *row = new QHBoxLayout(); row->setSpacing(4); row->addWidget(createSmallButton( tr("Add"), [actions, cardList, i]() { actions->actAddCardCounter(cardList, i); }, counterPage)); if (value > 0) { row->addWidget(createSmallButton( tr("Remove"), [actions, cardList, i]() { actions->actRemoveCardCounter(cardList, i); }, counterPage)); } row->addWidget(createSmallButton( tr("Set..."), [actions, cardList, i]() { actions->actRequestSetCardCounterDialog(cardList, i); }, counterPage)); layout->addLayout(row); } layout->addStretch(1); } void CardHoverWidget::buildTokenTab(CardItem *card, bool canModify) { auto *layout = tokenPageLayout; if (!card || !currentPlayer) { layout->addStretch(1); return; } auto *actions = currentPlayer->getLogic()->getPlayerActions(); ExactCard exactCard = card->getCard(); bool anyToken = false; if (canModify && !exactCard.isEmpty()) { QList relatedCards = exactCard.getInfo().getAllRelatedCards(); bool hasMulti = relatedCards.size() > 1; int known = 0; for (CardRelation *relation : relatedCards) { ExactCard related = CardDatabaseManager::query()->getCardFromSameSet(relation->getName(), exactCard.getPrinting()); if (!related) { related = CardDatabaseManager::query()->getCard({relation->getName()}); } if (!related) { continue; } if (known == 0) { layout->addWidget(createSectionHeader(tr("Tokens"), tokenPage)); } ++known; QString relatedName = related.getInfo().getPowTough().isEmpty() ? related.getName() : related.getInfo().getPowTough() + " " + related.getName(); CardRelation *captured = relation; CardItem *source = card; layout->addWidget(createSmallButton( tr("Token: ") + relatedName, [actions, source, captured]() { actions->actRequestCreateRelatedFromRelationDialog(source->getState(), captured); }, tokenPage)); } if (hasMulti && known > 0) { layout->addWidget(createSmallButton( tr("All tokens"), [actions, card]() { actions->actCreateAllRelatedCards(card->getState()); }, tokenPage)); } anyToken = known > 0; } // Save the tab index before adding related-view buttons so the header only shows when useful int beforeRelated = layout->count(); addRelatedViewButtons(layout); bool anyRelated = layout->count() != beforeRelated; if (!anyToken && !anyRelated) { layout->addWidget(createNoteLabel(tr("No tokens or related cards."), tokenPage)); } layout->addStretch(1); } void CardHoverWidget::addRelatedViewButtons(QVBoxLayout *layout) { if (!currentCard) { return; } ExactCard exactCard = currentCard->getCard(); if (exactCard.isEmpty()) { return; } QList relatedCards = exactCard.getInfo().getAllRelatedCards(); bool anyKnown = false; for (const CardRelation *relation : relatedCards) { if (CardDatabaseManager::query()->getCardInfo(relation->getName())) { anyKnown = true; break; } } if (!anyKnown) { return; } QWidget *page = layout == tokenPageLayout ? tokenPage : contentStack; layout->addWidget(createSectionHeader(tr("Related cards"), page)); for (const CardRelation *relation : relatedCards) { QString name = relation->getName(); if (!CardDatabaseManager::query()->getCardInfo(name)) { continue; } layout->addWidget(createSmallButton( name, [this, name, exactCard]() { ExactCard related = CardDatabaseManager::query()->getCard({name, exactCard.getPrinting().getUuid()}); if (!related.isEmpty()) { text->setCard(related); showPage(PAGE_INFO); } }, page)); } } // ---------- Layout helper ---------- void CardHoverWidget::clearLayout(QLayout *layout) { if (!layout) { return; } QLayoutItem *item; while ((item = layout->takeAt(0)) != nullptr) { if (item->widget()) { item->widget()->deleteLater(); } if (item->layout()) { clearLayout(item->layout()); } delete item; } } // ---------- Overlay positioning ---------- void CardHoverWidget::positionOverlays() { if (!currentCard || !pic) { return; } nameBarOverlay->show(); int containerWidth = imageContainer->width(); int containerHeight = imageContainer->height(); // Name bar at top — width constrained, height from layout int nameWidth = containerWidth - 8; nameBarOverlay->setFixedWidth(nameWidth); nameBarOverlay->adjustSize(); nameBarOverlay->move(4, 4); // P/T overlay at bottom-right ptOverlay->adjustSize(); int ptWidth = ptOverlay->sizeHint().width(); int ptHeight = ptOverlay->sizeHint().height(); ptOverlay->setGeometry(containerWidth - ptWidth - 4, containerHeight - ptHeight - 4, ptWidth, ptHeight); // Show/hide based on card data bool hasPT = !currentCard->getPT().isEmpty(); ptOverlay->setVisible(hasPT); } void CardHoverWidget::resizeEvent(QResizeEvent *event) { QFrame::resizeEvent(event); positionOverlays(); } // ---------- Display updates ---------- void CardHoverWidget::updatePTDisplay() { if (!currentCard) { ptPowerLabel->clear(); ptToughLabel->clear(); return; } QString pt = currentCard->getPT(); if (pt.isEmpty()) { ptPowerLabel->setText("--"); ptToughLabel->setText("--"); } else { int slashIdx = pt.indexOf('/'); if (slashIdx >= 0) { ptPowerLabel->setText(pt.left(slashIdx)); ptToughLabel->setText(pt.mid(slashIdx + 1)); } else { ptPowerLabel->setText(pt); ptToughLabel->setText("--"); } } } // ---------- Card data ---------- void CardHoverWidget::setCard(CardItem *card, PlayerGraphicsItem *player) { disconnectCardSignals(); if (!card || !player) { clearCard(); return; } currentCard = card; currentPlayer = player; ExactCard exactCard = card->getCard(); shownCard = exactCard; pic->setCard(exactCard); text->setCard(exactCard); nameLabel->setText(exactCard.getInfo().getName()); navNameLabel->setText(exactCard.getInfo().getName()); connectCardSignals(); refreshDisplay(); rebuildActionsForZone(); positionOverlays(); // The widget may have been disabled by a previous teardown; it is interactive again. setEnabled(true); } void CardHoverWidget::refreshDisplay() { updatePTDisplay(); if (!currentCard) { annotationLabel->setVisible(false); return; } QString annotation = currentCard->getAnnotation(); if (annotation.isEmpty()) { annotationLabel->setVisible(false); } else { annotationLabel->setText(annotation); annotationLabel->setVisible(true); } } void CardHoverWidget::clearCard() { // Go non-interactive synchronously: the action buttons below are only deleteLater'd, // so without this a click landing before deferred deletion runs would invoke a lambda // holding the destroyed card/player. Disabled widgets deliver no button clicks, which // also covers the fade-out window (hideAnimated only fades; hide() lands on finish). setEnabled(false); disconnectCardSignals(); refreshTimer->stop(); currentCard = nullptr; currentPlayer = nullptr; shownCard = ExactCard(); pic->setCard(ExactCard()); text->setCard(ExactCard()); nameLabel->clear(); navNameLabel->clear(); nameEdit->clear(); annotationLabel->clear(); annotationLabel->setVisible(false); ptPowerLabel->clear(); ptToughLabel->clear(); ptOverlay->setVisible(false); clearLayout(mainPageLayout); clearLayout(movePageLayout); clearLayout(counterPageLayout); clearLayout(tokenPageLayout); showPage(PAGE_INFO); } bool CardHoverWidget::hasCard() const { return currentCard != nullptr; } // ---------- Positioning: the picture lands exactly over the card, tabs spill to the side ---------- void CardHoverWidget::positionNextTo(const QRectF &cardSceneRect, const QTransform &viewportTransform, const QWidget *viewport) { QRect cardViewRect = viewportTransform.mapRect(cardSceneRect).toRect(); QPoint cardCenter = viewport->mapToGlobal(cardViewRect.center()); QSize popupSize = sizeHint(); popupSize = QSize(qMax(popupSize.width(), TAB_MIN_WIDTH + NAV_BUTTON_WIDTH + CARD_IMAGE_WIDTH + 40), qMax(popupSize.height(), TAB_MIN_HEIGHT + 16)); auto *screen = QGuiApplication::screenAt(cardCenter); if (!screen) { screen = QGuiApplication::primaryScreen(); } QRect screenRect = screen->availableGeometry(); // Clamp oversized popups to the screen before choosing a side popupSize.setWidth(qMin(popupSize.width(), screenRect.width() - 2 * POPUP_GAP)); popupSize.setHeight(qMin(popupSize.height(), screenRect.height() - 2 * POPUP_GAP)); const int mLeft = mainLayout->contentsMargins().left(); const int mRight = mainLayout->contentsMargins().right(); const int mTop = mainLayout->contentsMargins().top(); const int halfImgW = CARD_IMAGE_WIDTH / 2; // Picture height at full size (the pic has a fixed scale-factor size here; the morph // collapse happens later in showAnimated). const int imageH = pic->sizeHint().height(); // Center the picture over the card (not corner-pinned) so the overhang is even on // all sides; only the nav panel spills out to the side. Prefer spilling right, then left. const int xRight = cardCenter.x() - mLeft - halfImgW; const int xLeft = cardCenter.x() + mRight + halfImgW - popupSize.width(); const bool rightFits = xRight + popupSize.width() <= screenRect.right() - POPUP_GAP; const bool leftFits = xLeft >= screenRect.left() + POPUP_GAP; popupOnRight = rightFits || !leftFits; mainLayout->setDirection(popupOnRight ? QBoxLayout::LeftToRight : QBoxLayout::RightToLeft); int x = popupOnRight ? xRight : xLeft; // Center the picture vertically on the card (it is top-aligned in its container). int y = cardCenter.y() - mTop - imageH / 2; if (y + popupSize.height() > screenRect.bottom() - POPUP_GAP) { y = screenRect.bottom() - popupSize.height() - POPUP_GAP; } if (y < screenRect.top() + POPUP_GAP) { y = screenRect.top() + POPUP_GAP; } x = qMax(screenRect.left() + POPUP_GAP, x); x = qMin(screenRect.right() - popupSize.width() - POPUP_GAP, x); move(x, y); resize(popupSize); } // ---------- Show / hide: no window travel, the board zoom is the morph ---------- // The panel opens instantly at its final geometry (GameScene positioned it with the // picture centered over the zoomed card) and only fades in. Any geometry animation // here read as the panel "flying in", so there is none — not even for card switches. void CardHoverWidget::showAnimated() { if (!hasCard()) { return; } fadeOut->stop(); // Fresh open: fade the whole panel in over the zoomed card. Card switches while // visible keep full opacity and just show. bool wasHidden = !isVisible(); if (wasHidden) { setWindowOpacity(0.0); } show(); positionOverlays(); fadeIn->setStartValue(windowOpacity()); fadeIn->setEndValue(1.0); fadeIn->start(); } void CardHoverWidget::hideAnimated() { fadeIn->stop(); fadeOut->setStartValue(windowOpacity()); fadeOut->setEndValue(0.0); fadeOut->start(); } // ---------- Event filter ---------- bool CardHoverWidget::eventFilter(QObject *obj, QEvent *event) { // Handle double-click on name label to start editing annotation if (obj == nameLabel && event->type() == QEvent::MouseButtonDblClick) { nameLabel->setVisible(false); annotationLabel->setVisible(false); nameEdit->setVisible(true); nameEdit->setText(currentCard ? currentCard->getAnnotation() : QString()); nameEdit->setFocus(); nameEdit->selectAll(); return true; } // Handle click on card image to select the card in game if (obj == pic && event->type() == QEvent::MouseButtonPress) { QMouseEvent *me = static_cast(event); if (me->button() == Qt::LeftButton && currentCard) { if (auto *scene = qobject_cast(currentCard->scene())) { scene->setActiveCard(currentCard); } currentCard->setSelected(true); } } return QFrame::eventFilter(obj, event); } // ---------- Mouse tracking on the popup itself ---------- void CardHoverWidget::changeEvent(QEvent *event) { if (event->type() == QEvent::PaletteChange) { applyThemeStyle(); } QFrame::changeEvent(event); } void CardHoverWidget::enterEvent(QEnterEvent *event) { QFrame::enterEvent(event); emit hoverEntered(); } void CardHoverWidget::leaveEvent(QEvent *event) { QFrame::leaveEvent(event); // Moving the cursor onto a child widget also triggers leaveEvent on this widget; // only report a real departure from the popup. if (rect().contains(mapFromGlobal(QCursor::pos()))) { return; } emit hoverLeft(); }