From b2f0528fc76660981cf17f9762208436989d8e58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Wed, 14 Oct 2026 19:20:39 +0200 Subject: [PATCH] [Game] Add hover card panel with tabbed actions --- cockatrice/CMakeLists.txt | 1 + .../widgets/cards/card_hover_widget.cpp | 1354 +++++++++++++++++ .../widgets/cards/card_hover_widget.h | 184 +++ 3 files changed, 1539 insertions(+) create mode 100644 cockatrice/src/interface/widgets/cards/card_hover_widget.cpp create mode 100644 cockatrice/src/interface/widgets/cards/card_hover_widget.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index cc6debdf0..215c589c3 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -172,6 +172,7 @@ set(cockatrice_SOURCES src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.cpp + src/interface/widgets/cards/card_hover_widget.cpp src/interface/widgets/cards/card_info_display_widget.cpp src/interface/widgets/cards/card_info_frame_widget.cpp src/interface/widgets/cards/card_info_picture_art_crop_widget.cpp diff --git a/cockatrice/src/interface/widgets/cards/card_hover_widget.cpp b/cockatrice/src/interface/widgets/cards/card_hover_widget.cpp new file mode 100644 index 000000000..742d3703a --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_hover_widget.cpp @@ -0,0 +1,1354 @@ +#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(); +} diff --git a/cockatrice/src/interface/widgets/cards/card_hover_widget.h b/cockatrice/src/interface/widgets/cards/card_hover_widget.h new file mode 100644 index 000000000..d76d6b29d --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_hover_widget.h @@ -0,0 +1,184 @@ +/** + * @file card_hover_widget.h + * @ingroup CardWidgets + * @brief Hover popup: zoomed picture plus a vertical icon nav strip beside stacked action pages. + * + * The board card smooth-zooms first; this panel then swaps in over it with oracle + * text and every right-click action, grouped by topic. + */ + +#ifndef CARDHOVERWIDGET_H +#define CARDHOVERWIDGET_H + +#include +#include +#include +#include +#include +#include +#include + +class CardInfoPictureWidget; +class CardInfoTextWidget; +class CardItem; +class CardState; +class PlayerGraphicsItem; +class QVBoxLayout; +class QHBoxLayout; +class QPushButton; +class QToolButton; +class QButtonGroup; +class QStackedWidget; +class QLabel; +class QLineEdit; +class QEnterEvent; +class QPropertyAnimation; +class QResizeEvent; +class QTimer; + +class CardHoverWidget : public QFrame +{ + Q_OBJECT + +public: + explicit CardHoverWidget(QWidget *parent = nullptr); + + void setCard(CardItem *card, PlayerGraphicsItem *player); + void clearCard(); + bool hasCard() const; + CardItem *getCardItem() const + { + return currentCard; + } + + void positionNextTo(const QRectF &cardSceneRect, const QTransform &viewportTransform, const QWidget *viewport); + + void showAnimated(); + void hideAnimated(); + + /** @brief Refreshes the PT, counter, and annotation labels from the current card. */ + void refreshDisplay(); + + bool isMouseOverWidget() const + { + return rect().contains(mapFromGlobal(QCursor::pos())); + } + +protected: + bool eventFilter(QObject *obj, QEvent *event) override; + void changeEvent(QEvent *event) override; + void enterEvent(QEnterEvent *event) override; + void leaveEvent(QEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + +signals: + void hoverEntered(); + void hoverLeft(); + +private slots: + void onCardChanged(); + void onCardDestroyed(); + void applyThemeStyle(); + +private: + QPointer currentCard; + QPointer currentPlayer; + ExactCard shownCard; + + // Which side of the card the panel ended up on (drives the image-adjacent layout flip) + bool popupOnRight = true; + + // Coalesces rapid state-change bursts into a single refresh + QTimer *refreshTimer; + + // Card image area (kept on the card-adjacent side) with overlaid controls + QHBoxLayout *mainLayout; + QWidget *imageContainer; + CardInfoPictureWidget *pic; + + // Name bar overlay (top of card image) + QWidget *nameBarOverlay; + QLabel *nameLabel; + QLineEdit *nameEdit; + QLabel *annotationLabel; + + // P/T controls overlay (bottom-right of card image) + QWidget *ptOverlay; + QLabel *ptPowerLabel; + QLabel *ptToughLabel; + QPushButton *btnIncP; + QPushButton *btnDecP; + QPushButton *btnIncT; + QPushButton *btnDecT; + QPushButton *btnSetPT; + QPushButton *btnResetPT; + + // Vertical icon nav strip plus stacked content pages (no nested tab bars) + QWidget *sideBar; + QLabel *navNameLabel; + QToolButton *navInfoBtn; + QToolButton *navMainBtn; + QToolButton *navMoveBtn; + QToolButton *navCounterBtn; + QToolButton *navTokenBtn; + QButtonGroup *navGroup; + + QStackedWidget *contentStack; + QWidget *infoPage; + QWidget *mainPage; + QWidget *movePage; + QWidget *counterPage; + QWidget *tokenPage; + QVBoxLayout *infoPageLayout; + QVBoxLayout *mainPageLayout; + QVBoxLayout *movePageLayout; + QVBoxLayout *counterPageLayout; + QVBoxLayout *tokenPageLayout; + CardInfoTextWidget *text; + + // Window fade (sole reveal animation: per-layer opacity effects flashed black + // on scroll areas, so the whole panel fades as one) + QPropertyAnimation *fadeIn; + QPropertyAnimation *fadeOut; + + void createImageArea(); + void createNameBarOverlay(); + void createPTOverlay(); + void createNavBar(); + QToolButton *createNavButton(const QString &title, const QString &iconName, int pageIndex); + void createContentStack(); + QWidget *makeStackPage(QVBoxLayout *&layout); + void showPage(int index); + + QLabel *createSectionHeader(const QString &text, QWidget *parent); + QLabel *createNoteLabel(const QString &text, QWidget *parent); + + void connectCardSignals(); + void disconnectCardSignals(); + void scheduleRefresh(); + + void rebuildActionsForZone(); + void buildMainTab(const QList &cardList, const QString &zoneName, bool canModify); + void buildMoveTab(const QList &cardList, const QString &zoneName, bool canModify); + void buildCounterTab(const QList &cardList, const QString &zoneName, bool canModify); + void buildTokenTab(CardItem *card, bool canModify); + void addRelatedViewButtons(QVBoxLayout *layout); + void updatePTDisplay(); + void positionOverlays(); + + void clearLayout(QLayout *layout); + + QPushButton *createOverlayButton(const QString &text, const QString &tooltip, const std::function &slot); + QToolButton *createGridButton(const QString &text, + const QString &iconName, + const std::function &slot, + QWidget *parent, + const QString &tooltip = QString()); + QPushButton *createSmallButton(const QString &text, const std::function &slot, QWidget *parent = nullptr); + QPushButton *createSmallButton(const QString &text, + const QString &tooltip, + const std::function &slot, + QWidget *parent = nullptr); +}; + +#endif // CARDHOVERWIDGET_H