diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index a09a81ac9..27ded5621 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/client/settings/shortcuts_settings.h b/cockatrice/src/client/settings/shortcuts_settings.h index 24790a407..202b39247 100644 --- a/cockatrice/src/client/settings/shortcuts_settings.h +++ b/cockatrice/src/client/settings/shortcuts_settings.h @@ -569,6 +569,9 @@ private: {"Player/aRevealToAll", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Reveal Selected Cards to All Players"), parseSequenceString(""), ShortcutGroup::Playing_Area)}, + {"Player/aShowHoverPanel", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Show Hover Panel for Card"), + parseSequenceString(""), + ShortcutGroup::Playing_Area)}, {"Player/aMoveToBottomLibrary", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Bottom of Library"), parseSequenceString("Ctrl+B"), ShortcutGroup::Move_selected)}, diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index 84e062cd7..f7ae1ff71 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -13,6 +13,7 @@ #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/utility/card_ref.h" +#include #include #include #include @@ -21,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -29,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -42,9 +45,19 @@ class QSizeF; +// Width the hovered card smooth-zooms to: exactly the hover panel picture size, so +// all four corners coincide when the panel swaps in over it. +static constexpr qreal HOVER_ZOOM_WIDTH = 270.0; +// The pre-panel hover enlargement. The smooth zoom is opt-in, so this stays the hover +// behavior whenever no zoom trigger is configured (see setHoverHighlighted). +static constexpr qreal LEGACY_HOVER_SCALE = 1.1; +static constexpr int HOVER_ZOOM_IN_MS = 220; +static constexpr int HOVER_ZOOM_OUT_MS = 140; +static constexpr int FADE_DURATION_MS = 140; + AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id) : ArrowTarget(_owner, parent), state(new CardState(this, nullptr, cardRef, _id)), tapAngle(0), - bgColor(Qt::transparent), isHovered(false), realZValue(0) + bgColor(Qt::transparent), isHovered(false), isHoverHighlighted(false), realZValue(0) { setCursor(Qt::OpenHandCursor); setFlag(ItemIsSelectable); @@ -54,12 +67,38 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef connect(state, &CardState::cardInfoChanged, this, &AbstractCardItem::onCardInfoChanged); connect(state, &CardState::cardRefChanged, this, &AbstractCardItem::onCardRefChanged); connect(state, &CardState::tappedChanged, this, &AbstractCardItem::onTappedChanged); - connect(state, &CardState::facedownChanged, this, [this] { update(); }); + connect(state, &CardState::facedownChanged, this, [this](bool faceDown) { + update(); + emit faceDownChanged(faceDown); + }); connect(state, &CardState::colorChanged, this, [this] { cacheBgColor(); update(); }); + hoverZoom = new QPropertyAnimation(this, "cardScale", this); + hoverZoom->setEasingCurve(QEasingCurve::InOutCubic); + // Settling the unhover waits for the shrink-out to finish: the stacking override, + // the clip escape and the ancestor lift all stay put while the card is still large, + // so siblings and the clip container cannot cut through the shrinking card. + connect(hoverZoom, &QPropertyAnimation::finished, this, [this] { + if (!isHovered) { + // The cursor may still rest on the card (e.g. wheel-up reverts the zoom), + // in which case the highlight keeps it raised. + setZValue(isHoverHighlighted ? ZValues::HOVERED_CARD : realZValue); + // Final repaint at the settled footprint: with no scale delta the animation + // emits no per-frame updates, and zones would keep a stale clip hole behind. + if (scene()) { + scene()->update(sceneBoundingRect()); + } + emit hoverShrinkFinished(this); + } + }); + + opacityAnim = new QPropertyAnimation(this, "cardOpacity", this); + opacityAnim->setDuration(FADE_DURATION_MS); + opacityAnim->setEasingCurve(QEasingCurve::OutCubic); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this, [this] { update(); }); connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, @@ -131,7 +170,7 @@ void AbstractCardItem::setRealZValue(qreal _zValue) // During hover, zValue is overridden to HOVERED_CARD. Layout operations // like reorganizeCards() call setRealZValue() on all cards including the // hovered one — skip setZValue() here to avoid clobbering the override. - if (!isHovered) { + if (!isHovered && !isHoverHighlighted) { setZValue(_zValue); } } @@ -229,9 +268,9 @@ void AbstractCardItem::paint(QPainter *painter, const QStyleOptionGraphicsItem * painter->setRenderHint(QPainter::Antialiasing, false); - if (isSelected() || isHovered) { + if (isSelected() || isHovered || isHoverHighlighted) { QPen pen; - if (isHovered) { + if (isHovered || isHoverHighlighted) { pen.setColor(Qt::yellow); } if (isSelected()) { @@ -257,14 +296,88 @@ void AbstractCardItem::setHovered(bool _hovered) // Mark the hovered card's current scene footprint dirty so overlapped // sibling zones (e.g. StackZone) repaint after the card moves away. if (scene()) { - scene()->update(sceneBoundingRect()); + const QRectF footprint = sceneBoundingRect(); + scene()->update(footprint); + // The hole disappears the moment the card is no longer hovered, even though the + // card has not changed size yet: tell the zones so they repaint it shut. + emit hoverFootprintChanged(footprint, footprint); } } isHovered = _hovered; - setZValue(_hovered ? ZValues::HOVERED_CARD : realZValue); - setScale(_hovered && SettingsCache::instance().cardsDisplay().getScaleCards() ? 1.1 : 1); - setTransformOriginPoint(_hovered ? CardDimensions::WIDTH_HALF_F : 0, _hovered ? CardDimensions::HEIGHT_HALF_F : 0); + // The stacking override is one-directional: it applies the moment the hover starts + // and only drops once the shrink-out finishes (see the hoverZoom finished handler). + // Restoring it here would let siblings paint over the still-enlarged card. + if (_hovered) { + setZValue(ZValues::HOVERED_CARD); + } + // Scale about the center in both directions: at scale 1 the origin is moot, + // and keeping it centered avoids drift while shrinking back down. + setTransformOriginPoint(CardDimensions::WIDTH_HALF_F, CardDimensions::HEIGHT_HALF_F); + + // Smooth-zoom up to the hover picture size (instead of the old 1.1 snap) so the + // hover panel can then swap in over the zoomed card with matching corners. + // The target is constant ON-SCREEN pixels: divide out the board view zoom so + // fullscreen (or zoomed) boards don't overshoot while the fixed-size panel matches. + qreal target = 1.0; + if (_hovered && SettingsCache::instance().cardsDisplay().getScaleCards()) { + const qreal w = boundingRect().width(); + qreal viewScale = 1.0; + if (scene() && !scene()->views().isEmpty()) { + if (const auto *view = scene()->views().first()) { + viewScale = qAbs(view->transform().m11()); + } + } + if (w > 0.0 && viewScale > 0.0) { + target = qBound(1.0, HOVER_ZOOM_WIDTH / (w * viewScale), 6.0); + } + } + hoverZoom->stop(); + hoverZoom->setStartValue(scale()); + hoverZoom->setEndValue(target); + hoverZoom->setDuration(_hovered ? HOVER_ZOOM_IN_MS : HOVER_ZOOM_OUT_MS); + hoverZoom->start(); + update(); +} + +void AbstractCardItem::setHoverHighlighted(bool highlighted) +{ + if (isHoverHighlighted == highlighted) { + return; + } + + if (highlighted) { + // The card-info widget listens for this: hovering still updates it right away. + processHoverEvent(); + } + + isHoverHighlighted = highlighted; + if (highlighted) { + setZValue(ZValues::HOVERED_CARD); + } + + // Fallback to the historical hover scale: the smooth hover zoom is opt-in and its + // trigger defaults to Off, so when hovering drives neither the zoom nor the panel + // (zoom trigger Off, panel not on hover) contact alone must still enlarge the card + // to the old 1.1x while scaleCards is on. Otherwise every existing user silently + // loses the hover enlargement on upgrade. + auto &display = SettingsCache::instance().cardsDisplay(); + const bool legacyHoverScale = display.getScaleCards() && + display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerOff && + display.getHoverPanelTrigger() != CardsDisplaySettings::HoverPanelTriggerHover; + if (legacyHoverScale) { + // Centered like the smooth zoom, so growing and shrinking never drift. + setTransformOriginPoint(CardDimensions::WIDTH_HALF_F, CardDimensions::HEIGHT_HALF_F); + hoverZoom->stop(); + hoverZoom->setStartValue(scale()); + hoverZoom->setEndValue(highlighted ? LEGACY_HOVER_SCALE : 1.0); + hoverZoom->setDuration(highlighted ? HOVER_ZOOM_IN_MS : HOVER_ZOOM_OUT_MS); + hoverZoom->start(); + } else if (!highlighted && !isHovered && hoverZoom->state() != QAbstractAnimation::Running) { + // A zoomed card keeps its raised z until the shrink-out finishes (its finished + // handler restores it); a highlight-only card has nothing to unwind. + setZValue(realZValue); + } update(); } @@ -273,6 +386,64 @@ void AbstractCardItem::setColor(const QString &_color) state->setColor(_color); } +qreal AbstractCardItem::getCardScale() const +{ + return scale(); +} + +void AbstractCardItem::setCardScale(qreal _scale) +{ + if (qFuzzyCompare(_scale, scale())) { + return; + } + // The scale animates every frame: keep the scene index in step or hover + // picking tests a stale rect and the hover oscillates on/off by itself. + // Repaint the union of the old and new footprints, not just the card: zones + // dodge the zoomed card via a clip hole computed at paint time, and without + // a repaint they keep a stale hole — covering the card mid-animation and + // leaving a black rect behind once the hover ends. + const QRectF oldFootprint = sceneBoundingRect(); + prepareGeometryChange(); + setScale(_scale); + const QRectF newFootprint = sceneBoundingRect(); + if (scene()) { + scene()->update(oldFootprint.united(newFootprint)); + // Marking the scene region dirty is not enough for cached items: a zone that cuts a + // hover hole out of itself keeps the hole from its last repaint, which then shows as + // an uncovered black rectangle under the zoom. + emit hoverFootprintChanged(oldFootprint, newFootprint); + } else { + update(); + } +} + +qreal AbstractCardItem::getCardOpacity() const +{ + return opacity(); +} + +void AbstractCardItem::setCardOpacity(qreal _opacity) +{ + setOpacity(_opacity); + update(); +} + +void AbstractCardItem::fadeCardOut() +{ + opacityAnim->stop(); + opacityAnim->setStartValue(opacity()); + opacityAnim->setEndValue(0.0); + opacityAnim->start(); +} + +void AbstractCardItem::fadeCardIn() +{ + opacityAnim->stop(); + opacityAnim->setStartValue(opacity()); + opacityAnim->setEndValue(1.0); + opacityAnim->start(); +} + void AbstractCardItem::cacheBgColor() { QChar colorChar; @@ -310,6 +481,7 @@ void AbstractCardItem::cacheBgColor() void AbstractCardItem::onTappedChanged(bool newTapped, bool canAnimate) { + emit tappedChanged(newTapped); if (SettingsCache::instance().cardsDisplay().getTapAnimation() && canAnimate) { static_cast(scene())->registerAnimationItem(this); } else { diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.h b/cockatrice/src/game_graphics/board/abstract_card_item.h index eee8ec743..82058f08a 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_item.h @@ -23,10 +23,13 @@ class PlayerLogic; class CardInfo; class QPainter; class QPoint; +class QPropertyAnimation; class AbstractCardItem : public ArrowTarget, public IAnimatedItem { Q_OBJECT + Q_PROPERTY(qreal cardScale READ getCardScale WRITE setCardScale) + Q_PROPERTY(qreal cardOpacity READ getCardOpacity WRITE setCardOpacity) protected: CardState *state; int tapAngle; @@ -34,9 +37,12 @@ protected: private: bool isHovered; + bool isHoverHighlighted; qreal realZValue; bool pendingSelectionCollapse; int selectionCollapseGeneration = 0; ///< Guards the deferred collapse timer against stale firings + QPropertyAnimation *hoverZoom = nullptr; + QPropertyAnimation *opacityAnim = nullptr; private slots: void pixmapUpdated(); void onCardInfoChanged(); @@ -57,6 +63,19 @@ signals: void playSelectedFaceDown(AbstractCardItem *card); void hideSelected(AbstractCardItem *card); void selectionChanged(AbstractCardItem *card, bool selected); + void tappedChanged(bool tapped); + void faceDownChanged(bool faceDown); + /** @brief Emitted when the hover shrink-out animation runs to completion while unhovered. */ + void hoverShrinkFinished(AbstractCardItem *card); + /** + * @brief Emitted when the hover zoom changes this card's scene footprint. + * + * Zones (and anything else that dodges the zoomed card) draw from the live footprint at + * paint time but are cached: they need this to invalidate, or a stale hole is left behind. + * Also emitted once when the hover ends, with both rects equal, because the hole + * disappears without the card changing size. + */ + void hoverFootprintChanged(const QRectF &oldFootprint, const QRectF &newFootprint); public: enum @@ -116,6 +135,23 @@ public: } void setRealZValue(qreal _zValue); void setHovered(bool _hovered); + /** + * @brief Shows the hover outline and stacking lift, plus the historical 1.1x hover + * scale whenever no zoom trigger is configured. + * + * The board zoom is trigger-gated; the hover feedback (card info, outline, raised + * stacking) still follows the cursor immediately on contact. With the zoom trigger + * off and the panel not on hover, the old 1.1x enlargement (gated by scaleCards) + * stays in effect so upgrading does not silently drop it. + */ + void setHoverHighlighted(bool highlighted); + qreal getCardScale() const; + void setCardScale(qreal scale); + qreal getCardOpacity() const; + void setCardOpacity(qreal opacity); + /** @brief Fades the card out under the hover panel, or back in when it closes. */ + void fadeCardOut(); + void fadeCardIn(); bool getIsHovered() const { return isHovered; diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index 02c0427d7..9cc57f2b6 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -117,6 +117,11 @@ void CardItem::deleteLater() void CardItem::setZone(CardZoneLogic *_zone) { state->setZone(_zone); + // A card faded out under the open hover panel must come back as soon as it changes + // zones; otherwise it stays invisible at its destination until the panel closes. + if (opacity() < 1.0) { + fadeCardIn(); + } } void CardItem::retranslateUi() diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 128899a13..b4c6e1690 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -2,6 +2,7 @@ #include "../client/settings/cache_settings.h" #include "../interface/theme_manager.h" +#include "../interface/widgets/cards/card_hover_widget.h" #include "animated_item.h" #include "board/abstract_card_item.h" #include "board/arrow_item.h" @@ -11,16 +12,21 @@ #include "player/menu/player_menu.h" #include "player/player_graphics_item.h" #include "player/player_target.h" +#include "z_values.h" #include "zones/card_zone.h" #include "zones/select_zone.h" +#include "zones/table_zone.h" #include "zones/view_zone.h" #include "zones/view_zone_widget.h" #include +#include #include #include #include +#include #include +#include #include #include #include @@ -29,9 +35,11 @@ #include #include #include +#include #include #include -#include +#include +#include #include #include #include @@ -43,6 +51,7 @@ #include #include #include +#include #include #include #include @@ -75,6 +84,22 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent) } }); + // Card hover popup (cardHoverWidget is created lazily on first show) + hoverActivateTimer = new QTimer(this); + hoverActivateTimer->setSingleShot(true); + hoverActivateTimer->setInterval(500); + connect(hoverActivateTimer, &QTimer::timeout, this, &GameScene::onHoverActivateTimeout); + + hoverPanelTimer = new QTimer(this); + hoverPanelTimer->setSingleShot(true); + hoverPanelTimer->setInterval(200); + connect(hoverPanelTimer, &QTimer::timeout, this, &GameScene::onHoverPanelTimeout); + + hoverGraceTimer = new QTimer(this); + hoverGraceTimer->setSingleShot(true); + hoverGraceTimer->setInterval(200); + connect(hoverGraceTimer, &QTimer::timeout, this, &GameScene::onHoverGraceTimeout); + rearrange(); } @@ -93,6 +118,9 @@ GameScene::~GameScene() delete animationTimer; animationTimer = nullptr; + delete hoverActivateTimer; + delete hoverPanelTimer; + delete hoverGraceTimer; // Delete all ArrowItems before QGraphicsScene's base destructor runs. // QGraphicsScene::~QGraphicsScene() destroys items in arbitrary order. @@ -239,6 +267,23 @@ void GameScene::onCardRightClicked(AbstractCardItem *abstractCard, QPoint screen return; } + // The zoom and panel triggers are independently bindable: a right-click fires + // whichever phases listen to it. When the panel is the trigger it carries every + // action itself, so the context menu stays suppressed; when right-click only zooms, + // the menu still opens, since nothing else would summon it. + auto &display = SettingsCache::instance().cardsDisplay(); + const bool zoomFired = display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerRightClick; + const bool panelFired = display.getHoverPanelTrigger() == CardsDisplaySettings::HoverPanelTriggerRightClick; + if (zoomFired || panelFired) { + fireTriggers(card, zoomFired, panelFired); + if (panelFired) { + return; + } + } + + // Hide the hover popup when the context menu opens + hideCardHoverPopup(); + setActiveCard(card); if (auto *menu = view->getPlayerMenu()->updateCardMenu(card->getState())) { @@ -690,7 +735,6 @@ void GameScene::requestArrowDeletion(int playerId, int arrowId) emit arrowDeletionRequested(playerId, arrowId); } } - void GameScene::onCardZoneChanged(CardState *card, bool sameZone) { auto *item = qobject_cast(card->parent()); @@ -710,6 +754,11 @@ void GameScene::onCardZoneChanged(CardState *card, bool sameZone) for (auto *arrow : toDelete) { deleteArrow(arrow->getCreatorId(), arrow->getId()); } + + // The hover panel stays open on a moved card: follow it instead of stranding. + if (!sameZone && cardHoverWidget && cardHoverWidget->getCardItem() == item) { + positionCardHoverPopup(); + } } void GameScene::onCardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner) @@ -746,27 +795,137 @@ void GameScene::clearArrowsForPlayerLocally(int playerId) } } } - // ---------- Hover Handling ---------- +CardItem *GameScene::getHoveredCard() const +{ + return hoveredCard; +} + void GameScene::updateHover(const QPointF &scenePos) { - auto itemList = items(scenePos, Qt::IntersectsItemBoundingRect, Qt::DescendingOrder, getViewTransform()); + updateHoveredCard(pickCardAt(scenePos)); +} +CardItem *GameScene::pickCardAt(const QPointF &scenePos) +{ + auto itemList = items(scenePos, Qt::IntersectsItemBoundingRect, Qt::DescendingOrder, getViewTransform()); CardZone *zone = findTopmostZone(itemList); CardItem *topCard = zone ? findTopmostCardInZone(itemList, zone) : nullptr; - updateHoveredCard(topCard); + if (!topCard && hoveredCard) { + const QRectF guard = hoveredCard->sceneBoundingRect().adjusted(-8, -8, 8, 8); + if (guard.contains(scenePos)) { + // The zoomed card overflows onto neighboring zones, where naive topmost-zone + // picking attributes the cursor to the zone underneath. While the cursor is + // still over the zoomed footprint (with hysteresis for edge jiggle), keep it. + // A genuinely different topmost card still takes over. + return hoveredCard; + } + } + return topCard; } void GameScene::updateHoveredCard(CardItem *newCard) { + // Safety: if the popup's card was destroyed, hide the popup immediately + if (cardHoverWidget && cardHoverWidget->isVisible() && !cardHoverWidget->hasCard()) { + hideCardHoverPopup(); + } + // The hovered card may have been destroyed without ending the hover: its QPointer + // nulled itself, but the ancestor lift is still outstanding. Restore it now. + if (!hoveredCard && !hoverLiftedAncestors.isEmpty()) { + restoreHoverLift(); + } + + // Hovering alone never starts anything anymore: the trigger (dwell, click, wheel, + // shortcut) resolves first, then the zoom follows. This keeps the feature calm. + bool wasHoveringCard = (hoveredCard != nullptr); if (hoveredCard && (newCard != hoveredCard)) { endCardHover(hoveredCard); + hoveredCard = nullptr; } - if (newCard && (newCard != hoveredCard)) { - beginCardHover(newCard); + + // The hover outline and the card info follow the cursor on contact: only the board + // zoom and the panel wait for their trigger. A highlight-only card has no zoom to + // unwind, so it is dropped directly instead of through endCardHover(). + if (hoverHighlightCard && hoverHighlightCard != newCard) { + hoverHighlightCard->setHoverHighlighted(false); + } + hoverHighlightCard = newCard; + if (newCard) { + newCard->setHoverHighlighted(true); + } + + auto &display = SettingsCache::instance().cardsDisplay(); + const int zoomTrigger = display.getHoverZoomTrigger(); + const int panelTrigger = display.getHoverPanelTrigger(); + const bool doubleConfirm = display.getHoverPanelDoubleConfirm() && zoomTrigger == panelTrigger; + + if (newCard) { + // Cancel any pending grace timer (mouse is back on a card) + hoverGracePending = false; + hoverGraceTimer->stop(); + + if (newCard == hoveredCard) { + // Steady state: zoom already up. Refresh an open panel, otherwise nothing — + // in particular this must not re-fire an armed double-confirm. + if (cardHoverWidget && cardHoverWidget->isVisible() && cardHoverWidget->getCardItem() == newCard) { + cardHoverWidget->refreshDisplay(); + } + return; + } + if (cardHoverWidget && cardHoverWidget->isVisible() && !cardHoverWidget->isClosing() && + cardHoverWidget->getCardItem() == newCard) { + // Panel open but zoom dropped (e.g. survived a Leave) — zoom back and refresh. + beginCardHover(newCard); + hoveredCard = newCard; + cardHoverWidget->refreshDisplay(); + return; + } + if (cardHoverWidget && cardHoverWidget->isVisible() && !cardHoverWidget->isClosing()) { + // A different card is hovered while the panel is up: close the old panel + // first, then fall through to first contact so the new card follows the + // normal dwell instead of the old panel trailing the cursor. + hideCardHoverPopup(); + confirmArmedCard.clear(); + } + if (zoomTrigger != CardsDisplaySettings::HoverPanelTriggerHover && + panelTrigger != CardsDisplaySettings::HoverPanelTriggerHover) { + // Mouse contact alone drives neither phase in this config. + return; + } + if (doubleConfirm && confirmArmedCard == newCard) { + // Repeat hover on an armed card: zoom and show at once, no second dwell. + beginCardHover(newCard); + hoveredCard = newCard; + confirmArmedCard.clear(); + showCardHoverPopup(newCard); + return; + } + if (newCard != hoverPopupTarget) { + // First contact (or a new card): dwell, then resolve on timeout. + hoverPopupTarget = newCard; + hoverActivateTimer->start(display.getHoverZoomDelayMs()); + } + } else { + // No card hovered: drop any pending dwell/panel timers (kills stale shows) and start + // the grace timer only when the mouse just left a card with the panel open. + hoverPopupTarget = nullptr; + hoverPanelTarget = nullptr; + hoverActivateTimer->stop(); + hoverPanelTimer->stop(); + // Non-hover zoom arms only make sense while approaching the card: leaving + // re-arms the zoom for the next explicit trigger instead of showing the panel. + // (Hover-trigger arms survive the leave so re-entering completes the repeat.) + if (zoomTrigger != CardsDisplaySettings::HoverPanelTriggerHover) { + confirmArmedCard.clear(); + } + if (wasHoveringCard && cardHoverWidget && cardHoverWidget->isVisible() && + !cardHoverWidget->isMouseOverWidget() && !hoverGracePending) { + hoverGracePending = true; + hoverGraceTimer->start(); + } } - hoveredCard = newCard; } void GameScene::beginCardHover(CardItem *card) @@ -775,16 +934,340 @@ void GameScene::beginCardHover(CardItem *card) if (auto *zone = SelectZone::findOwningSelectZone(card)) { zone->escapeClipForHover(card); } + // Lift the whole ancestor chain above their siblings: the zoomed card overflows its + // own zone, and Qt orders whole subtrees level by level, so no single z-value wins + // every comparison on its own. Harmless elsewhere (areas and zones tile without + // overlapping; arrows, zone views and drag items keep their higher layers). + // Restore first: if a previous hover never ended (its card was destroyed mid-hover), + // its chain is still outstanding and must land before a new one is recorded. + restoreHoverLift(); + QGraphicsItem *ancestor = card->parentItem(); + while (ancestor) { + // The chain is QGraphicsObject-derived in practice; a plain QGraphicsItem + // ancestor cannot be guarded, so it keeps its z-value instead of risking an + // unrestorable lift. + if (QGraphicsObject *ancestorObject = ancestor->toGraphicsObject()) { + hoverLiftedAncestors.append({QPointer(ancestorObject), ancestor->zValue()}); + ancestor->setZValue(ZValues::HOVERED_AREA); + } + ancestor = ancestor->parentItem(); + } + liftOwner = card; + connect(card, &AbstractCardItem::hoverShrinkFinished, this, &GameScene::onHoverShrinkFinished, + Qt::UniqueConnection); + connect(card, &AbstractCardItem::hoverFootprintChanged, this, &GameScene::onHoverFootprintChanged, + Qt::UniqueConnection); } void GameScene::endCardHover(CardItem *card) { - if (auto *zone = SelectZone::findOwningSelectZone(card)) { - zone->restoreClipAfterHover(card); - } + // Only the zoom-out starts here: the z-value, the clip escape and the ancestor + // lift stay put until the shrink finishes (onHoverShrinkFinished), so siblings and + // the clip container cannot cut through the still-enlarged card. card->setHovered(false); } +void GameScene::onHoverShrinkFinished(AbstractCardItem *card) +{ + auto *cardItem = qobject_cast(card); + if (!cardItem) { + return; + } + // Per-card and idempotent: always settle, even if a newer hover owns the lift. + if (auto *zone = SelectZone::findOwningSelectZone(cardItem)) { + zone->restoreClipAfterHover(cardItem); + } + if (liftOwner != cardItem) { + // A newer hover already owns the lift (or nothing does): leave it alone. + return; + } + restoreHoverLift(); +} + +void GameScene::onHoverFootprintChanged(const QRectF &oldFootprint, const QRectF &newFootprint) +{ + const QRectF dirty = oldFootprint.united(newFootprint); + if (dirty.isEmpty()) { + return; + } + // TableZone cuts the hovered card out of its own paint, but it is cached: the card + // changing size does not dirty the zone, so the previous hole would stay transparent in + // the cache — an uncovered black rectangle under the zoom whenever the card grows across + // a zone boundary. Nudge only the zones the footprint touches; the scene repaint itself + // is already scheduled by the card. + const auto overlapping = items(dirty, Qt::IntersectsItemBoundingRect); + for (QGraphicsItem *item : overlapping) { + if (auto *zone = qobject_cast(item->toGraphicsObject())) { + zone->update(); + } + } +} + +void GameScene::restoreHoverLift() +{ + for (const auto &[lifted, zValue] : hoverLiftedAncestors) { + if (lifted) { + lifted->setZValue(zValue); + } + } + hoverLiftedAncestors.clear(); + liftOwner.clear(); +} + +// ---------- Card Hover Popup ---------- + +void GameScene::showCardHoverPopup(CardItem *card) +{ + if (!card || !card->getOwner()) { + return; + } + + PlayerGraphicsItem *playerView = playerViews.value(card->getOwner()->getPlayerInfo()->getId()); + if (!playerView) { + return; + } + + hoverActivateTimer->stop(); + hoverPanelTimer->stop(); + hoverGracePending = false; + hoverGraceTimer->stop(); + hoverPopupTarget = card; + hoverPanelTarget = nullptr; + confirmArmedCard.clear(); + + if (!cardHoverWidget) { + auto *viewport = views().isEmpty() ? nullptr : views().first()->viewport(); + cardHoverWidget = new CardHoverWidget(viewport); + connect(cardHoverWidget, &CardHoverWidget::hoverEntered, this, &GameScene::onHoverWidgetEntered); + connect(cardHoverWidget, &CardHoverWidget::hoverLeft, this, &GameScene::onHoverWidgetLeft); + connect(cardHoverWidget, &CardHoverWidget::dismissRequested, this, &GameScene::hideCardHoverPopup); + + // Keep the popup anchored to its card while the view scrolls, zooms, or resizes + if (!views().isEmpty()) { + auto *view = views().first(); + connect(view->horizontalScrollBar(), &QScrollBar::valueChanged, this, &GameScene::positionCardHoverPopup); + connect(view->verticalScrollBar(), &QScrollBar::valueChanged, this, &GameScene::positionCardHoverPopup); + if (viewport) { + viewport->installEventFilter(this); + } + } + } + + // Crossfade: the opaque panel picture sits exactly over the zoomed card, so fade + // the board card out underneath it (masks any residual size/corner mismatch) and + // fade any previously shown card back in. + CardItem *oldCard = cardHoverWidget->hasCard() ? cardHoverWidget->getCardItem() : nullptr; + + cardHoverWidget->setCard(card, playerView); + positionCardHoverPopup(); + cardHoverWidget->showAnimated(); + + if (oldCard && oldCard != card) { + oldCard->fadeCardIn(); + } + card->fadeCardOut(); +} + +void GameScene::hideCardHoverPopup() +{ + // Hiding is terminal: drop all pending hover timing with it, or a dwell/panel + // timer that resolves afterwards pops the panel back up (e.g. over the context + // menu that hideCardHoverPopup was just called for). + hoverActivateTimer->stop(); + hoverPanelTimer->stop(); + hoverGracePending = false; + hoverGraceTimer->stop(); + if (cardHoverWidget) { + // The panel is gone: whatever card it covered must come back. + if (CardItem *coveredCard = cardHoverWidget->getCardItem()) { + coveredCard->fadeCardIn(); + } + if (cardHoverWidget->isVisible()) { + cardHoverWidget->hideAnimated(); + } + } + hoverPopupTarget = nullptr; + hoverPanelTarget = nullptr; +} + +void GameScene::positionCardHoverPopup() +{ + if (!cardHoverWidget || !cardHoverWidget->hasCard()) { + return; + } + + auto *viewport = views().isEmpty() ? nullptr : views().first()->viewport(); + if (!viewport) { + return; + } + + QRectF sceneRect = cardHoverWidget->getCardItem()->sceneBoundingRect(); + QTransform vpTransform = getViewportTransform(); + cardHoverWidget->positionNextTo(sceneRect, vpTransform, viewport); +} + +void GameScene::onHoverActivateTimeout() +{ + // Dwell resolved: the hover drives whichever phases listen to it. The cursor must + // still be over the pending card (rect test, not a fresh pick, so pick flakiness + // cannot veto it); otherwise this was a stale timer. + CardItem *target = hoverPopupTarget; + hoverPopupTarget = nullptr; + if (!target || views().isEmpty()) { + return; + } + auto *view = views().first(); + const QPointF cursorScene = view->mapToScene(view->viewport()->mapFromGlobal(QCursor::pos())); + if (!target->sceneBoundingRect().contains(cursorScene)) { + return; + } + auto &display = SettingsCache::instance().cardsDisplay(); + const bool zoomPhase = display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerHover; + const bool panelPhase = display.getHoverPanelTrigger() == CardsDisplaySettings::HoverPanelTriggerHover; + const bool effectiveDouble = + display.getHoverPanelDoubleConfirm() && display.getHoverZoomTrigger() == display.getHoverPanelTrigger(); + if (hoveredCard && hoveredCard != target) { + endCardHover(hoveredCard); + } + if (zoomPhase) { + if (hoveredCard != target) { + beginCardHover(target); + hoveredCard = target; + } + if (effectiveDouble) { + confirmArmedCard = target; + } + } + if (panelPhase && !effectiveDouble) { + hoverPanelTarget = target; + hoverPanelTimer->start(display.getHoverPanelDelayMs()); + } +} + +void GameScene::onHoverPanelTimeout() +{ + // Panel delay resolved: show, but only if the target card is still the one under + // the cursor (same cheap rect check — no dependence on pick stability). + CardItem *target = hoverPanelTarget; + hoverPanelTarget = nullptr; + if (!target || views().isEmpty()) { + return; + } + auto *view = views().first(); + const QPointF cursorScene = view->mapToScene(view->viewport()->mapFromGlobal(QCursor::pos())); + if (!target->sceneBoundingRect().contains(cursorScene)) { + return; + } + ensureZoomed(target); + showCardHoverPopup(target); +} + +void GameScene::onHoverGraceTimeout() +{ + hoverGracePending = false; + if (cardHoverWidget && cardHoverWidget->isVisible() && cardHoverWidget->isMouseOverWidget()) { + return; + } + hideCardHoverPopup(); +} + +void GameScene::summonHoverPanel() +{ + // Summon for whatever is under the cursor right now; hovering is not required. + if (views().isEmpty()) { + return; + } + auto *view = views().first(); + const QPointF cursorScene = view->mapToScene(view->viewport()->mapFromGlobal(QCursor::pos())); + if (CardItem *card = pickCardAt(cursorScene)) { + auto &display = SettingsCache::instance().cardsDisplay(); + fireTriggers(card, display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerShortcut, + display.getHoverPanelTrigger() == CardsDisplaySettings::HoverPanelTriggerShortcut); + } +} + +void GameScene::ensureZoomed(CardItem *card) +{ + if (hoveredCard != card) { + if (hoveredCard) { + endCardHover(hoveredCard); + } + beginCardHover(card); + hoveredCard = card; + } +} + +void GameScene::fireTriggers(CardItem *card, bool zoomFired, bool panelFired) +{ + if (!card || !card->getOwner()) { + return; + } + + if (cardHoverWidget && cardHoverWidget->isVisible() && !cardHoverWidget->isClosing() && + cardHoverWidget->getCardItem() == card) { + cardHoverWidget->refreshDisplay(); + return; + } + + // Explicit triggers preempt any pending hover timing. + hoverPopupTarget = nullptr; + hoverPanelTarget = nullptr; + hoverActivateTimer->stop(); + hoverPanelTimer->stop(); + + auto &display = SettingsCache::instance().cardsDisplay(); + const bool effectiveDouble = + display.getHoverPanelDoubleConfirm() && display.getHoverZoomTrigger() == display.getHoverPanelTrigger(); + + if (zoomFired && panelFired) { + // Both phases on one action: one-step zooms and shows at once (the crossfade + // covers it); double-confirm arms on first fire and shows on repeat. + if (effectiveDouble) { + if (confirmArmedCard == card) { + confirmArmedCard.clear(); + ensureZoomed(card); + showCardHoverPopup(card); + } else { + confirmArmedCard = card; + ensureZoomed(card); + } + } else { + ensureZoomed(card); + showCardHoverPopup(card); + } + return; + } + if (zoomFired) { + ensureZoomed(card); + } + if (panelFired) { + ensureZoomed(card); + showCardHoverPopup(card); + } +} + +void GameScene::onHoverWidgetEntered() +{ + hoverGracePending = false; + hoverGraceTimer->stop(); +} + +void GameScene::onHoverWidgetLeft() +{ + if (!hoveredCard && cardHoverWidget && cardHoverWidget->isVisible() && !hoverGracePending) { + hoverGracePending = true; + hoverGraceTimer->start(); + } +} + +bool GameScene::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::Resize) { + positionCardHoverPopup(); + } + return QGraphicsScene::eventFilter(watched, event); +} + CardZone *GameScene::findTopmostZone(const QList &items) { for (QGraphicsItem *item : items) { @@ -940,7 +1423,50 @@ bool GameScene::event(QEvent *event) { if (event->type() == QEvent::GraphicsSceneMouseMove) { updateHover(static_cast(event)->scenePos()); + } else if (event->type() == QEvent::GraphicsSceneWheel) { + // Wheel trigger: wheeling over a card fires whichever phases listen to it + // instead of scrolling. Fully consumed here: forwarding to the base dispatcher + // as well would double-deliver it to items under the cursor (e.g. zone views, + // which scroll on wheel). Scrolling up instead reverts an active zoom/panel — + // backing out of the gesture undoes it; with nothing to revert the event falls + // through to normal scrolling. + auto &display = SettingsCache::instance().cardsDisplay(); + const bool zoomFired = display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerWheel; + const bool panelFired = display.getHoverPanelTrigger() == CardsDisplaySettings::HoverPanelTriggerWheel; + if (zoomFired || panelFired) { + auto *wheelEvent = static_cast(event); + // QGraphicsSceneWheelEvent carries delta()/pixelDelta(), not angleDelta(). + const int wheelUp = !wheelEvent->pixelDelta().isNull() + ? wheelEvent->pixelDelta().y() + : (wheelEvent->orientation() == Qt::Vertical ? wheelEvent->delta() : 0); + if (wheelUp > 0) { + if ((cardHoverWidget && cardHoverWidget->isVisible()) || hoveredCard) { + hoverPopupTarget = nullptr; + hoverPanelTarget = nullptr; + confirmArmedCard.clear(); + hideCardHoverPopup(); + if (hoveredCard) { + endCardHover(hoveredCard); + hoveredCard = nullptr; + } + event->accept(); + return true; + } + } else if (CardItem *card = pickCardAt(wheelEvent->scenePos())) { + fireTriggers(card, zoomFired, panelFired); + event->accept(); + return true; + } + } } else if (event->type() == QEvent::Leave) { + // The hover panel opens directly over the zoomed card under the cursor, which the + // scene observes as a Leave. The cursor never actually left: if it is over the open + // panel, keep the hover (dropping it here shrinks the card from under the panel and + // starves the popup logic, so the menu never shows). A genuine leave of the window + // misses the panel and still ends the hover as before. + if (cardHoverWidget && cardHoverWidget->isVisible() && cardHoverWidget->isMouseOverWidget()) { + return QGraphicsScene::event(event); + } updateHoveredCard(nullptr); } diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index 0d4db70a8..9c8c422a3 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -11,8 +11,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -30,6 +32,7 @@ class CardZoneLogic; class IAnimatedItem; class QGraphicsItem; +class QGraphicsObject; class QPoint; struct ArrowData; template class QSharedPointer; @@ -48,6 +51,8 @@ class ServerInfo_Card; class PhasesToolbar; class QBasicTimer; class QObject; +class QTimer; +class CardHoverWidget; /** * @class GameScene @@ -73,16 +78,41 @@ private: ArrowRegistry arrowRegistry; ///< ID registry for arrow graphics items QList zoneViews; ///< Active zone view widgets QSize viewSize; ///< Current view size - QPointer hoveredCard; ///< Currently hovered card - QPointer activeCard; ///< Card the currently open context menu targets - QBasicTimer *animationTimer; ///< Timer for scene animations - QHash animatedItems; ///< Items currently animating + QPointer hoveredCard; ///< Currently hovered card (zoomed) + /// Card the cursor is resting on. It carries the hover outline and the card-info + /// update immediately, independently of the zoom/panel triggers. + QPointer hoverHighlightCard; + QPointer activeCard; ///< Card the currently open context menu targets + /// Card armed for double-confirm panel summon (first trigger zooms, repeat shows). + QPointer confirmArmedCard; + /// Ancestor chain of the hovered card with pre-hover Z-values. Lifted while hovering + /// because Qt orders subtrees level by level: the card z alone cannot beat a sibling + /// zone, and the top ancestor alone cannot beat same-area sibling zones either — every + /// level from the zone up must win its own comparison. Guarded pointers: if the card + /// is destroyed mid-hover (or an ancestor goes away first), the guards null out and + /// the next restore skips them instead of touching freed memory. An orphaned lift is + /// invisible — areas and zones tile without overlapping and all UI layers still win — + /// and any outstanding lift is restored by the next hover transition. + QList, qreal>> hoverLiftedAncestors; + /// Card whose ancestor chain is currently lifted (cleared together with the chain). + QPointer liftOwner; + QBasicTimer *animationTimer; ///< Timer for scene animations + QHash animatedItems; ///< Items currently animating QHash animationItemConnections; ///< destroyed->removeAnimatedItem handles per animated item int playerRotation; ///< Rotation offset for player layout bool rearranging = false; ///< Guard against re-entrant rearrange bool needsReArrange = false; ///< Pending rearrange requested during a pass + // Card hover popup + QPointer cardHoverWidget; ///< The hover popup widget + QTimer *hoverActivateTimer; ///< Dwell delay before the hover zoom kicks in + QTimer *hoverPanelTimer; ///< Delay after the zoom before the tab panel shows + QTimer *hoverGraceTimer; ///< Grace period before hiding popup (200ms) + QPointer hoverPopupTarget; ///< Card the popup is intended for + QPointer hoverPanelTarget; ///< Card the panel timer will show + bool hoverGracePending = false; ///< Grace timer armed (mouse left a card or the popup) + /** * @brief Updates which card is currently hovered based on scene coordinates. * @param scenePos Scene position of the cursor. @@ -96,6 +126,23 @@ private: void beginCardHover(CardItem *card); /** @brief Deactivates hover state and restores the card to its clip container. */ void endCardHover(CardItem *card); + /** @brief Restores every lifted ancestor to its pre-hover Z-value and drops the chain. */ + void restoreHoverLift(); + /** @brief Settles a finished shrink-out: clip container and ancestor lift restore. */ + void onHoverShrinkFinished(AbstractCardItem *card); + /** + * @brief Repaints the zones that dodge the zoomed card when its footprint moves. + * + * Those zones cut the hovered card out of their paint, but they are cached: another + * item's geometry change does not dirty them, so without this the previous hole stays + * transparent in the cache and a black rectangle shows under the zoom. + */ + void onHoverFootprintChanged(const QRectF &oldFootprint, const QRectF &newFootprint); + + // Card hover popup methods + void showCardHoverPopup(CardItem *card); + void hideCardHoverPopup(); + void positionCardHoverPopup(); public: /** @@ -227,6 +274,21 @@ public: /** @brief Updates hovered card highlighting. */ void updateHoveredCard(CardItem *newCard); + /** @brief Topmost card at a scene position, without side effects. */ + CardItem *pickCardAt(const QPointF &scenePos); + + /** + * @brief Fires the zoom and/or panel trigger for a card (one explicit user action). + * When both fire on one action, double-confirm arms on first fire and shows on repeat. + */ + void fireTriggers(CardItem *card, bool zoomFired, bool panelFired); + + /** @brief Starts the hover zoom for a card without touching panel state. */ + void ensureZoomed(CardItem *card); + + /** @brief The currently hovered card, if any (used by zones to dodge it while painting). */ + CardItem *getHoveredCard() const; + /** * @brief Registers an item for animation updates with the shared scene timer. * @@ -293,10 +355,22 @@ public slots: */ void onCardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner); + // Card hover popup slots + void onHoverActivateTimeout(); + void onHoverPanelTimeout(); + void onHoverGraceTimeout(); + void onHoverWidgetEntered(); + void onHoverWidgetLeft(); + /** @brief Summons the hover panel for the currently hovered card, if any. */ + void summonHoverPanel(); + protected: /** @brief Handles hover updates. */ bool event(QEvent *event) override; + /** @brief Repositions the hover popup when the viewport resizes. */ + bool eventFilter(QObject *watched, QEvent *event) override; + /** @brief Handles animation timer updates. */ void timerEvent(QTimerEvent *event) override; diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp index 7a5aa073a..59f374435 100644 --- a/cockatrice/src/game_graphics/game_view.cpp +++ b/cockatrice/src/game_graphics/game_view.cpp @@ -87,6 +87,10 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par connect(aCloseMostRecentZoneView, &QAction::triggered, scene, &GameScene::closeMostRecentZoneView); addAction(aCloseMostRecentZoneView); + + aShowHoverPanel = new QAction(this); + connect(aShowHoverPanel, &QAction::triggered, scene, &GameScene::summonHoverPanel); + addAction(aShowHoverPanel); connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &GameView::refreshShortcuts); refreshShortcuts(); @@ -246,6 +250,7 @@ void GameView::refreshShortcuts() { aCloseMostRecentZoneView->setShortcuts( SettingsCache::instance().shortcuts().getShortcut("Player/aCloseMostRecentZoneView")); + aShowHoverPanel->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("Player/aShowHoverPanel")); } void GameView::clearTallyLabels() diff --git a/cockatrice/src/game_graphics/game_view.h b/cockatrice/src/game_graphics/game_view.h index 99a73a0a8..52c06ebcb 100644 --- a/cockatrice/src/game_graphics/game_view.h +++ b/cockatrice/src/game_graphics/game_view.h @@ -29,6 +29,7 @@ class GameView : public QGraphicsView Q_OBJECT private: QAction *aCloseMostRecentZoneView; + QAction *aShowHoverPanel; QRubberBand *rubberBand; QLabel *dragCountLabel; QLabel *totalCountLabel; diff --git a/cockatrice/src/game_graphics/z_values.h b/cockatrice/src/game_graphics/z_values.h index c6e7f2c8a..8e198102b 100644 --- a/cockatrice/src/game_graphics/z_values.h +++ b/cockatrice/src/game_graphics/z_values.h @@ -34,6 +34,10 @@ constexpr qreal OVERLAY_BASE = ZValueLayerManager::OVERLAY_BASE; // Overlay layer Z-values for items that should appear above normal cards constexpr qreal HOVERED_CARD = ZValueLayerManager::overlayZValue(1.0); +// Lifts the hovered card's whole player area above sibling areas (and their zone +// decorations) while hovering. One level only: Qt compares subtrees at their highest +// differing ancestor, so the top ancestor decides. Below arrows/views/drags on purpose. +constexpr qreal HOVERED_AREA = ZValueLayerManager::overlayZValue(2.0); constexpr qreal ARROWS = ZValueLayerManager::overlayZValue(3.0); constexpr qreal ZONE_VIEW_WIDGET = ZValueLayerManager::overlayZValue(4.0); constexpr qreal DRAG_ITEM = ZValueLayerManager::overlayZValue(5.0); diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index 0dffab353..dd2b3e38e 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -34,6 +35,7 @@ #include #include #include +#include #include namespace google @@ -118,6 +120,19 @@ bool TableZone::isInverted() const void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) { + // The hovered (zoomed) card may spill over this zone: cut it out of every layer + // below so washes, indicators, dimming and the land divider never paint over it, + // whatever the stacking order is. + const std::optional hoverHole = hoveredCardHole(); + const bool clipped = hoverHole.has_value(); + if (clipped && hoverHole->isEmpty()) { + // The zoom covers this zone entirely: there is nothing left of it to paint. + return; + } + if (clipped) { + painter->setClipPath(*hoverHole); + } + if (playmatActive) { // Subtle overlay to distinguish table zone from stack zone painter->fillRect(boundingRect(), QColor(0, 0, 0, 60)); @@ -142,6 +157,39 @@ void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*opti } paintLandDivider(painter); + + if (clipped) { + painter->setClipping(false); + } +} + +/** + * @brief This zone's paint area minus the hovered card's painted outline, in zone coordinates. + * + * @c std::nullopt when no card is hovered or it does not overlap this zone; an *empty* path when + * the zoom covers this zone entirely, which means nothing of it is left to paint. Lets every + * paint layer dodge the zoomed card instead of painting over it. + */ +std::optional TableZone::hoveredCardHole() const +{ + auto *scene = qobject_cast(this->scene()); + if (!scene) { + return std::nullopt; + } + CardItem *hovered = scene->getHoveredCard(); + if (!hovered) { + return std::nullopt; + } + // Cut out the card's painted outline, not its bounding rectangle: the rectangle's corners + // are never covered by the artwork (rounded corners, antialiased edges), so they would stay + // black around the zoom. + const QPainterPath cardOutline = sceneTransform().inverted().map(hovered->sceneTransform().map(hovered->shape())); + if (!cardOutline.boundingRect().intersects(boundingRect())) { + return std::nullopt; + } + QPainterPath zoneArea; + zoneArea.addRect(boundingRect()); + return zoneArea.subtracted(cardOutline); } void TableZone::onPlaymatChanged(bool active) diff --git a/cockatrice/src/game_graphics/zones/table_zone.h b/cockatrice/src/game_graphics/zones/table_zone.h index 8aa4936ad..6beb54d30 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.h +++ b/cockatrice/src/game_graphics/zones/table_zone.h @@ -18,6 +18,7 @@ #include #include #include +#include #include class CardDragItem; @@ -26,6 +27,7 @@ class CardZoneLogic; class QColor; class QGraphicsItem; class QPainter; +class QPainterPath; template class QList; /** @@ -221,6 +223,13 @@ private: void paintZoneOutline(QPainter *painter); void paintLandDivider(QPainter *painter); + /** + * @brief This zone's paint area minus the hovered card's painted outline, in zone coordinates. + * + * @c std::nullopt when no card is hovered or it does not overlap this zone; an *empty* path when + * the hovered card covers this zone entirely, in which case nothing of the zone is visible. + */ + [[nodiscard]] std::optional hoveredCardHole() const; /* Calculates card stack widths so mapping functions work properly diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index 2efa2fd13..bd8da9407 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -94,8 +94,10 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, searchEdit.setFocusPolicy(Qt::ClickFocus); searchEdit.setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit.setClearButtonEnabled(true); - searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); - auto help = searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), + QLineEdit::LeadingPosition); + auto help = searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/info"), IconTint::quiet()), + QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); }); diff --git a/cockatrice/src/interface/palette_editor/palette_generator.cpp b/cockatrice/src/interface/palette_editor/palette_generator.cpp index d5d70c4ef..94ad5492c 100644 --- a/cockatrice/src/interface/palette_editor/palette_generator.cpp +++ b/cockatrice/src/interface/palette_editor/palette_generator.cpp @@ -178,9 +178,14 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch cfg.appColors[AppColor::IconStrong] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Primary); cfg.appColors[AppColor::IconSoft] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Quiet); // Semantic roles stay independent of the accent: a generated palette must - // still paint add/ready green and remove/not-ready red. - for (const AppColor::Role role : {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft, - AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) { + // still paint add/ready green, remove/not-ready red, information blue, + // hidden information violet, marks amber and movement teal. + for (const AppColor::Role role : + {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft, AppColor::Negative, + AppColor::NegativeStrong, AppColor::NegativeSoft, AppColor::Informational, AppColor::InformationalStrong, + AppColor::InformationalSoft, AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft, AppColor::Marked, + AppColor::MarkedStrong, AppColor::MarkedSoft, AppColor::Movement, AppColor::MovementStrong, + AppColor::MovementSoft}) { cfg.appColors[role] = defaultAppColor(role, dark); } diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index 73e821b90..e7d91d330 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -78,6 +78,22 @@ static const QMap APP_ROLE_DESCRIPTIONS = { QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state and removal actions (not ready, remove, decrement)")}, {AppColor::NegativeStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state at primary emphasis")}, {AppColor::NegativeSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state at quiet emphasis")}, + {AppColor::Informational, + QT_TRANSLATE_NOOP("PaletteGridWidget", + "Informational state that is neither a gain nor a loss (face-down, exile)")}, + {AppColor::InformationalStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Informational state at primary emphasis")}, + {AppColor::InformationalSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Informational state at quiet emphasis")}, + {AppColor::Hidden, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Hidden information (face-down play, peek at the card face)")}, + {AppColor::HiddenStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Hidden information at primary emphasis")}, + {AppColor::HiddenSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Hidden information at quiet emphasis")}, + {AppColor::Marked, QT_TRANSLATE_NOOP("PaletteGridWidget", "Marks left on a card (annotation, draw arrow)")}, + {AppColor::MarkedStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Marks on a card at primary emphasis")}, + {AppColor::MarkedSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Marks on a card at quiet emphasis")}, + {AppColor::Movement, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Cards moving without a gain or a loss (library placements)")}, + {AppColor::MovementStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Movement at primary emphasis")}, + {AppColor::MovementSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Movement at quiet emphasis")}, }; PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index 037293af9..2705d74ca 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -448,7 +448,8 @@ QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded) // handing a DPR-1 bitmap to a label that would upscale and blur it on a 2x // display. const QString prefix = QStringLiteral("icons/") + (expanded ? "dropdown_expanded" : "dropdown_collapsed"); - const QPixmap pixmap = loadThemedIcon(prefix).pixmap(QSize(height, height), qApp->devicePixelRatio()); + const QPixmap pixmap = + loadThemedIcon(prefix, IconTint::quiet()).pixmap(QSize(height, height), qApp->devicePixelRatio()); pmCache.insert(key, pixmap); return pixmap; } diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index 848be2231..525a46905 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -482,6 +482,42 @@ QColor defaultAppColor(AppColor::Role role, bool dark) return QColor(dark ? QStringLiteral("#ff8378") : QStringLiteral("#da0b00")); case AppColor::NegativeSoft: return QColor(dark ? QStringLiteral("#c79e9b") : QStringLiteral("#935f5c")); + // Informational is the third semantic family: a state that is neither a + // gain nor a loss (a face-down play, the exile zone). It carries the + // same three-level ladder so its emphasis is authored, not computed. + case AppColor::Informational: + return QColor(dark ? QStringLiteral("#7aa9f2") : QStringLiteral("#1c64c4")); + case AppColor::InformationalStrong: + return QColor(dark ? QStringLiteral("#69aeff") : QStringLiteral("#0a4fb5")); + case AppColor::InformationalSoft: + return QColor(dark ? QStringLiteral("#9db2cd") : QStringLiteral("#526a8a")); + // Hidden is the game's hidden-information cluster: a face-down play and + // peeking at a card face. Violet keeps it apart from the blue + // Informational family, which carries public information (reveal). + case AppColor::Hidden: + return QColor(dark ? QStringLiteral("#c6a5fa") : QStringLiteral("#6d28d9")); + case AppColor::HiddenStrong: + return QColor(dark ? QStringLiteral("#c99bff") : QStringLiteral("#4c0f9e")); + case AppColor::HiddenSoft: + return QColor(dark ? QStringLiteral("#b3a6c9") : QStringLiteral("#6a5a8c")); + // Marked is the marks a player leaves on a card: an annotation or a + // draw arrow. Amber names a persistent, removable mark rather than a + // gain or a loss, so annotation and arrow finally share a colour. + case AppColor::Marked: + return QColor(dark ? QStringLiteral("#e2a63a") : QStringLiteral("#9a5b05")); + case AppColor::MarkedStrong: + return QColor(dark ? QStringLiteral("#ffb020") : QStringLiteral("#8a4f02")); + case AppColor::MarkedSoft: + return QColor(dark ? QStringLiteral("#c2ab7d") : QStringLiteral("#7b6a4a")); + // Movement is a card changing position without a gain or a loss: the + // library placements (top, bottom, X from top). Teal keeps it apart + // from the green "into play" moves and the blue informational states. + case AppColor::Movement: + return QColor(dark ? QStringLiteral("#4cc2b5") : QStringLiteral("#0e7a72")); + case AppColor::MovementStrong: + return QColor(dark ? QStringLiteral("#2dd4bf") : QStringLiteral("#096b62")); + case AppColor::MovementSoft: + return QColor(dark ? QStringLiteral("#a3c1bc") : QStringLiteral("#4a6b67")); } return {}; diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 68734e762..0848baeb8 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -15,11 +15,11 @@ // beyond what Qt's palette can express. // // The icon families carry one role per emphasis level. Icon is the neutral -// glyph ink; IconStrong and IconSoft are its primary and quiet levels. Positive -// and Negative follow the same pattern, so an emphasis never has to be computed -// from a hue at paint time: the theme authors all three levels. Naming a variant -// explicitly (`IconStrong`) pins that level; naming the family (`Icon`) lets the -// caller's emphasis choose. +// glyph ink; IconStrong and IconSoft are its primary and quiet levels. Positive, +// Negative, Informational, Hidden, Marked and Movement follow the same pattern, +// so an emphasis never has to be computed from a hue at paint time: the theme +// authors all three levels. Naming a variant explicitly (`IconStrong`) pins that +// level; naming the family (`Icon`) lets the caller's emphasis choose. namespace AppColor { Q_NAMESPACE @@ -36,6 +36,18 @@ enum Role Negative, NegativeStrong, NegativeSoft, + Informational, + InformationalStrong, + InformationalSoft, + Hidden, + HiddenStrong, + HiddenSoft, + Marked, + MarkedStrong, + MarkedSoft, + Movement, + MovementStrong, + MovementSoft, }; Q_ENUM_NS(Role) } // namespace AppColor diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp index f0c9ef84f..3a71f149c 100644 --- a/cockatrice/src/interface/theme_icon.cpp +++ b/cockatrice/src/interface/theme_icon.cpp @@ -54,6 +54,22 @@ AppColor::Role emphasisRole(AppColor::Role role, IconEmphasis emphasis) return emphasis == IconEmphasis::Primary ? AppColor::NegativeStrong : emphasis == IconEmphasis::Quiet ? AppColor::NegativeSoft : AppColor::Negative; + case AppColor::Informational: + return emphasis == IconEmphasis::Primary ? AppColor::InformationalStrong + : emphasis == IconEmphasis::Quiet ? AppColor::InformationalSoft + : AppColor::Informational; + case AppColor::Hidden: + return emphasis == IconEmphasis::Primary ? AppColor::HiddenStrong + : emphasis == IconEmphasis::Quiet ? AppColor::HiddenSoft + : AppColor::Hidden; + case AppColor::Marked: + return emphasis == IconEmphasis::Primary ? AppColor::MarkedStrong + : emphasis == IconEmphasis::Quiet ? AppColor::MarkedSoft + : AppColor::Marked; + case AppColor::Movement: + return emphasis == IconEmphasis::Primary ? AppColor::MovementStrong + : emphasis == IconEmphasis::Quiet ? AppColor::MovementSoft + : AppColor::Movement; default: return role; } diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 1ad0309b2..3a2ecf9cf 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -683,6 +683,18 @@ QColor ThemeManager::appColor(AppColor::Role role) const case AppColor::Negative: case AppColor::NegativeStrong: case AppColor::NegativeSoft: + case AppColor::Informational: + case AppColor::InformationalStrong: + case AppColor::InformationalSoft: + case AppColor::Hidden: + case AppColor::HiddenStrong: + case AppColor::HiddenSoft: + case AppColor::Marked: + case AppColor::MarkedStrong: + case AppColor::MarkedSoft: + case AppColor::Movement: + case AppColor::MovementStrong: + case AppColor::MovementSoft: // Semantic colors are independent of the accent: a missing role must // not silently paint "remove" in the theme's highlight color. return defaultAppColor(role, isDarkModeActive()); 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..27625db52 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_hover_widget.cpp @@ -0,0 +1,1736 @@ +#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 "../../theme_icon.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 +#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; + +// P/T pill: centred on the card's printed P/T box, in fractions of the picture rect. +static constexpr double PT_PILL_ANCHOR_X = 0.92; +static constexpr double PT_PILL_ANCHOR_Y = 0.94; +static constexpr int PT_PILL_EDGE_INSET = 2; +static constexpr int PT_PILL_MIN_WIDTH = 48; +static constexpr int PT_PILL_MIN_HEIGHT = 22; +static constexpr int PT_PILL_TEXT_PADDING = 4; + +// Hover trigger: the bottom-right quadrant of the art, around the printed P/T box. +static constexpr double PT_HOVER_REGION_WIDTH = 0.35; +static constexpr double PT_HOVER_REGION_HEIGHT = 0.20; +// Halo around the resting pill that still counts as "on the P/T box" for opening the rows. +static constexpr int PT_OPEN_HALO = 4; + +enum HoverPanelPage +{ + PAGE_INFO, + PAGE_MAIN, + PAGE_MOVE, + PAGE_COUNTER, + PAGE_TOKEN +}; + +// The action grid is what the panel exists for, so it carries the primary ink; +// the category tabs only navigate between pages and stay quiet. Both use the +// line glyph: the filled treatments read as a second state, and every cell in +// the grid would carry one. +static QIcon categoryTabIcon(const QString &prefix) +{ + return loadThemedIcon(prefix, IconTint::quiet()); +} + +// Every action carries a hue naming a game fact the glyph cannot: a gain, a +// loss, information, hidden information, a mark on the card or a move. Keeping +// one line glyph per action means a row of different families still reads as a +// single grid instead of a set of buttons from different toolbars. +static QIcon semanticActionIcon(const QString &prefix, AppColor::Role role) +{ + return loadThemedIcon(prefix, IconTint(role, IconEmphasis::Primary)); +} + +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;" + "}" + // Accent edge on the resting P/T pill: the cue that the + // control rows live behind it, and the divider once they lift out. + "QLabel#cardHoverPTPill {" + " border-top: 2px solid %5;" + "}") + .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); +} + +/** @brief Clamps @p start so that a run of @p size stays within [@p origin, @p origin + @p extent]. */ +static int clampToSpan(int origin, int extent, int size, int start) +{ + if (size >= extent) { + return origin; + } + return qBound(origin, start, origin + extent - size); +} + +// ---------- 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); + // The artwork rect is only known after the pixmap is drawn (letterboxing, sideways-card + // rotation), so re-anchor the overlays once it lands. + connect(pic, &CardInfoPictureWidget::artRectChanged, this, &CardHoverWidget::positionOverlays, + Qt::QueuedConnection); +} + +// ---------- 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 (canModifyCurrentCard()) { + if (auto *actions = currentCard->getOwner()->getPlayerActions()) { + 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): pill at rest, controls on hover ---------- + +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(); + }; + + // Control rows: hidden at rest, shown while the P/T area is hovered. + ptControls = new QWidget(ptOverlay); + ptControls->setObjectName("cardHoverPTControls"); + ptControls->setMouseTracking(true); + ptControls->setStyleSheet(QStringLiteral("background: transparent;")); + + // Value pill: the resting state, and the bottom (never-moving) row of the stack. + // The accent top edge is the only cue that controls sit behind the pill; it doubles + // as the divider between the pill and the rows once they are lifted out. + ptPillLabel = new QLabel(ptOverlay); + ptPillLabel->setObjectName("cardHoverPTPill"); + ptPillLabel->setAlignment(Qt::AlignCenter); + // The accent top edge (the only cue that controls sit behind the pill) comes from the + // palette-driven sheet in applyThemeStyle(), so it follows theme switches. + ptPillLabel->setStyleSheet(QStringLiteral("color: white; font-weight: bold; font-size: 12px;" + " background: transparent; padding: 0 %1px;") + .arg(PT_PILL_TEXT_PADDING)); + ptPillLabel->setMinimumHeight(PT_PILL_MIN_HEIGHT); + // Seed text: the resting size has to exist before the first card is shown. + ptPillLabel->setText(QStringLiteral("00/00")); + + ptPowerLabel = new QLabel(ptControls); + 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(ptControls); + 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..."), [this, getActions, cardList]() { + runModalAction([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 *controlsLayout = new QVBoxLayout(ptControls); + controlsLayout->setContentsMargins(4, 3, 4, 3); + controlsLayout->setSpacing(2); + controlsLayout->addLayout(powerRow); + controlsLayout->addLayout(toughRow); + controlsLayout->addLayout(btnRow); + ptControls->setLayout(controlsLayout); + + auto *ptLayout = new QVBoxLayout(ptOverlay); + ptLayout->setContentsMargins(0, 0, 0, 0); + ptLayout->setSpacing(2); + ptLayout->addWidget(ptControls); + ptLayout->addWidget(ptPillLabel); + ptOverlay->setLayout(ptLayout); + + // Both states are driven from sizes cached here: hiding the controls would shrink + // layout()->sizeHint(), so the two states must not be measured from the live layout. + ptFullSize = ptLayout->sizeHint(); + ptControls->setVisible(false); + ptCompactSize = measureCompactPTSize(); + ptPillLabel->clear(); + ptOverlay->resize(ptCompactSize); + + // The whole subtree feeds the hover state: moving onto a button must not read as a leave. + ptOverlay->installEventFilter(this); + const QList ptChildren = ptOverlay->findChildren(); + for (QWidget *child : ptChildren) { + child->installEventFilter(this); + } + + ptOverlay->setVisible(false); +} + +// ---------- 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"), categoryTabIcon(QStringLiteral("icons/info")), PAGE_INFO); + navMainBtn = createNavButton(tr("Main"), categoryTabIcon(QStringLiteral("icons/settings")), PAGE_MAIN); + navMoveBtn = createNavButton(tr("Move"), categoryTabIcon(QStringLiteral("icons/swap")), PAGE_MOVE); + navCounterBtn = createNavButton(tr("Counters"), categoryTabIcon(QStringLiteral("icons/increment")), PAGE_COUNTER); + navTokenBtn = createNavButton(tr("Tokens"), categoryTabIcon(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 QIcon &icon, int pageIndex) +{ + auto *btn = new QToolButton(sideBar); + btn->setObjectName("cardHoverNavBtn"); + btn->setText(title); + btn->setToolTip(title); + btn->setIcon(icon); + 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 QIcon &icon, + 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(icon); + 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) ---------- + +bool CardHoverWidget::canModifyCurrentCard() const +{ + if (!currentCard || !currentCard->getOwner() || !currentPlayer) { + return false; + } + if (!currentPlayer->getLogic()->getPlayerInfo()->getLocalOrJudge()) { + return false; + } + // A revealed zone we cannot write to is read-only even for its owner. + if (auto *view = qobject_cast(currentCard->getZone())) { + if (view->getRevealZone() && !view->getWriteableRevealZone()) { + return false; + } + } + return true; +} + +void CardHoverWidget::rebuildActionsForZone() +{ + clearLayout(mainPageLayout); + clearLayout(movePageLayout); + clearLayout(counterPageLayout); + clearLayout(tokenPageLayout); + + if (!currentCard || !currentPlayer) { + return; + } + + auto *card = currentCard.data(); + QList cardList{card->getState()}; + + const bool canModify = canModifyCurrentCard(); + + 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"), semanticActionIcon(QStringLiteral("icons/arrow_left"), AppColor::Informational), + [actions, cardList]() { actions->actHide(cardList); }, mainPage)); + grid.addButton(createGridButton(tr("Clone"), + semanticActionIcon(QStringLiteral("icons/increment"), AppColor::Positive), + invoke(cmClone), mainPage)); + grid.addSection(createSectionHeader(tr("Select"), mainPage)); + grid.addButton(createGridButton( + tr("Select All"), semanticActionIcon(QStringLiteral("icons/search"), AppColor::Informational), + [gameScene, card]() { gameScene->selectAllInZone(card); }, mainPage)); + grid.addButton(createGridButton( + tr("Select Column"), semanticActionIcon(QStringLiteral("icons/filter"), AppColor::Informational), + [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"), + semanticActionIcon(QStringLiteral("icons/sync"), AppColor::Informational), + invoke(cmTap), mainPage)); + auto *doesntUntap = createGridButton( + tr("Skip untapping"), semanticActionIcon(QStringLiteral("icons/lock"), AppColor::Informational), + [actions, cardList]() { actions->cardMenuAction(cardList, cmDoesntUntap); }, mainPage); + doesntUntap->setCheckable(true); + doesntUntap->setChecked(card->getDoesntUntap()); + grid.addButton(doesntUntap); + grid.addButton(createGridButton(tr("Clone"), + semanticActionIcon(QStringLiteral("icons/increment"), AppColor::Positive), + invoke(cmClone), mainPage)); + grid.addButton(createGridButton(tr("Attach to card..."), + semanticActionIcon(QStringLiteral("icons/pin"), AppColor::Positive), attach, + mainPage)); + grid.addButton(createGridButton(tr("Draw arrow..."), + semanticActionIcon(QStringLiteral("icons/share"), AppColor::Marked), drawArrow, + mainPage)); + grid.addButton(createGridButton( + tr("Set annotation..."), semanticActionIcon(QStringLiteral("icons/pencil"), AppColor::Marked), + [this, actions, cardList]() { + runModalAction([actions, cardList]() { actions->actRequestSetAnnotationDialog(cardList); }); + }, + mainPage)); + grid.addButton(createGridButton(tr("Turn Over"), + semanticActionIcon(QStringLiteral("icons/swap"), AppColor::Hidden), + invoke(cmFlip), mainPage)); + if (card->getFaceDown()) { + grid.addButton(createGridButton(tr("Peek at card face"), + semanticActionIcon(QStringLiteral("icons/view"), AppColor::Hidden), + invoke(cmPeek), mainPage)); + } + if (card->getAttachedTo()) { + grid.addButton(createGridButton( + tr("Unattach"), semanticActionIcon(QStringLiteral("icons/arrow_undo"), AppColor::Negative), + [actions, cardList]() { actions->actUnattach(cardList); }, mainPage)); + } + grid.addButton(createGridButton( + tr("Reduce life"), semanticActionIcon(QStringLiteral("icons/arrow_down"), AppColor::Negative), + [actions, cardList]() { actions->actReduceLifeByPower(cardList); }, mainPage)); + grid.addSection(createSectionHeader(tr("Select"), mainPage)); + grid.addButton(createGridButton( + tr("Select All"), semanticActionIcon(QStringLiteral("icons/search"), AppColor::Informational), + [gameScene, card]() { gameScene->selectAllInZone(card); }, mainPage)); + grid.addButton(createGridButton( + tr("Select Row"), semanticActionIcon(QStringLiteral("icons/filter"), AppColor::Informational), + [gameScene, card]() { gameScene->selectSameRow(card); }, mainPage)); + } else if (!zoneName.isEmpty()) { + ButtonGrid grid(layout); + grid.addSection(createSectionHeader(tr("Zone"), mainPage)); + grid.addButton(createGridButton( + tr("Play"), semanticActionIcon(QStringLiteral("icons/arrow_right"), AppColor::Positive), + [actions, cardList]() { actions->actPlay(cardList); }, mainPage)); + grid.addButton(createGridButton( + tr("Play Face Down"), semanticActionIcon(QStringLiteral("icons/arrow_right"), AppColor::Hidden), + [actions, cardList]() { actions->actPlayFacedown(cardList); }, mainPage)); + grid.addButton(createGridButton(tr("Clone"), + semanticActionIcon(QStringLiteral("icons/increment"), AppColor::Positive), + invoke(cmClone), mainPage)); + if (zoneName == ZoneNames::STACK || zoneName == ZoneNames::HAND || zoneName == ZoneNames::GRAVE || + zoneName == ZoneNames::EXILE) { + grid.addButton(createGridButton(tr("Attach to card..."), + semanticActionIcon(QStringLiteral("icons/pin"), AppColor::Positive), attach, + mainPage)); + grid.addButton(createGridButton(tr("Draw arrow..."), + semanticActionIcon(QStringLiteral("icons/share"), AppColor::Marked), + drawArrow, mainPage)); + } + grid.addSection(createSectionHeader(tr("Select"), mainPage)); + grid.addButton(createGridButton( + tr("Select All"), semanticActionIcon(QStringLiteral("icons/search"), AppColor::Informational), + [gameScene, card]() { gameScene->selectAllInZone(card); }, mainPage)); + if (qobject_cast(card->getZone())) { + grid.addButton(createGridButton( + tr("Select Column"), semanticActionIcon(QStringLiteral("icons/filter"), AppColor::Informational), + [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"), + semanticActionIcon(QStringLiteral("icons/arrow_to_top"), AppColor::Movement), + invoke(cmMoveToTopLibrary), movePage)); + grid.addButton(createGridButton( + tr("X from top..."), semanticActionIcon(QStringLiteral("icons/search"), AppColor::Movement), + [actions]() { actions->actRequestMoveCardXCardsFromTopDialog(); }, movePage, tr("X from top of library..."))); + grid.addButton(createGridButton(tr("Bottom of library"), + semanticActionIcon(QStringLiteral("icons/arrow_to_bottom"), AppColor::Movement), + invoke(cmMoveToBottomLibrary), movePage)); + grid.addButton(createGridButton(tr("Table"), semanticActionIcon(QStringLiteral("icons/player"), AppColor::Positive), + invoke(cmMoveToTable), movePage)); + grid.addButton(createGridButton(tr("Hand"), + semanticActionIcon(QStringLiteral("icons/arrow_left"), AppColor::Positive), + invoke(cmMoveToHand), movePage)); + grid.addButton(createGridButton(tr("Graveyard"), + semanticActionIcon(QStringLiteral("icons/arrow_down"), AppColor::Negative), + invoke(cmMoveToGraveyard), movePage)); + grid.addButton(createGridButton(tr("Exile"), + semanticActionIcon(QStringLiteral("icons/arrow_right"), AppColor::Informational), + 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"), semanticActionIcon(QStringLiteral("icons/share"), AppColor::Informational), + [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, semanticActionIcon(QStringLiteral("icons/player"), AppColor::Informational), + [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..."), + [this, actions, cardList]() { + runModalAction([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..."), + [this, actions, cardList, i]() { + runModalAction([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, + [this, actions, source, captured]() { + runModalAction([actions, source, captured]() { + actions->actRequestCreateRelatedFromRelationDialog(source->getState(), captured); + }); + }, + tokenPage)); + } + if (hasMulti && known > 0) { + layout->addWidget(createSmallButton( + tr("All tokens"), + [this, actions, card]() { + runModalAction([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(); + + // Name bar at top — width constrained, height from layout + int nameWidth = containerWidth - 8; + nameBarOverlay->setFixedWidth(nameWidth); + nameBarOverlay->adjustSize(); + nameBarOverlay->move(4, 4); + + // Show/hide based on card data; an empty P/T always falls back to the compact pill + const bool hasPT = !currentCard->getPT().isEmpty(); + if (!hasPT && ptExpanded) { + setPTExpanded(false); + } + applyPTOverlayGeometry(); + ptOverlay->setVisible(hasPT); +} + +void CardHoverWidget::setPTExpanded(bool expanded) +{ + if (!ptOverlay || !ptControls) { + return; + } + // Nothing to expand into when the card has no P/T. + if (expanded && (!currentCard || currentCard->getPT().isEmpty())) { + expanded = false; + } + // The buttons act on our own copy of the card by id, so on another player's card they + // would edit our zone. Offer them only where the action tabs allow edits. + if (expanded && !canModifyCurrentCard()) { + expanded = false; + } + // Mouse movement drives this on every event; only apply a real state change. + if (ptExpanded == expanded) { + return; + } + + ptExpanded = expanded; + ptControls->setVisible(expanded); + if (expanded) { + // The stack covers more of the art than the pill did; keep it above sibling overlays. + ptOverlay->raise(); + } + applyPTOverlayGeometry(); +} + +void CardHoverWidget::applyPTOverlayGeometry() +{ + if (!ptOverlay || !pic || ptCompactSize.isEmpty() || ptFullSize.isEmpty()) { + return; + } + // Anchor on the drawn artwork, not on imageContainer: the container can be taller than the + // picture, and sideways-layout cards are letterboxed inside pic. Both used to drop the + // overlay below the printed P/T box. + const QRect picture = + pic->artRect() + .translated(pic->pos()) + .adjusted(PT_PILL_EDGE_INSET, PT_PILL_EDGE_INSET, -PT_PILL_EDGE_INSET, -PT_PILL_EDGE_INSET); + const QRect compact = compactPillRect(); + if (picture.isEmpty() || compact.isEmpty()) { + return; + } + + // Expanded: same bottom edge and right edge as the pill, so the controls grow upward + // and never appear under the cursor that is resting on the pill. Never narrower than + // the current pill, or a three-digit value would clip against the stack's edge. + const QSize size = + ptExpanded ? QSize(qMax(ptFullSize.width(), ptCompactSize.width()), ptFullSize.height()) : ptCompactSize; + const int x = clampToSpan(picture.x(), picture.width(), size.width(), compact.x() + compact.width() - size.width()); + const int y = + clampToSpan(picture.y(), picture.height(), size.height(), compact.y() + compact.height() - size.height()); + + ptOverlay->setGeometry(x, y, size.width(), size.height()); +} + +QRect CardHoverWidget::compactPillRect() const +{ + if (!pic || ptCompactSize.isEmpty()) { + return {}; + } + const QRect picture = + pic->artRect() + .translated(pic->pos()) + .adjusted(PT_PILL_EDGE_INSET, PT_PILL_EDGE_INSET, -PT_PILL_EDGE_INSET, -PT_PILL_EDGE_INSET); + if (picture.isEmpty()) { + return {}; + } + // Centred on the printed P/T box (bottom-right of the art). + const int anchorX = picture.x() + qRound(picture.width() * PT_PILL_ANCHOR_X); + const int anchorY = picture.y() + qRound(picture.height() * PT_PILL_ANCHOR_Y); + return QRect( + clampToSpan(picture.x(), picture.width(), ptCompactSize.width(), anchorX - ptCompactSize.width() / 2), + clampToSpan(picture.y(), picture.height(), ptCompactSize.height(), anchorY - ptCompactSize.height() / 2), + ptCompactSize.width(), ptCompactSize.height()); +} + +QRect CardHoverWidget::ptOpenRegion() const +{ + const QRect pill = compactPillRect(); + if (pill.isEmpty() || !pic) { + return {}; + } + // The opening target is the P/T box itself (plus a small halo), not the whole bottom-right + // quadrant: a wider trigger lifts the rows out from under a cursor that merely passed by, + // and the click that selects the card would land on a P/T button instead. + return pill.translated(-pic->pos()).adjusted(-PT_OPEN_HALO, -PT_OPEN_HALO, PT_OPEN_HALO, PT_OPEN_HALO); +} + +QRect CardHoverWidget::ptHoverRegion() const +{ + if (!pic) { + return {}; + } + // The band is relative to the drawn artwork, so a letterboxed (sideways-layout) card keeps + // its P/T corner where the printed box actually is. + const QRect art = pic->artRect(); + if (art.isEmpty()) { + return {}; + } + return QRect(art.x() + qRound(art.width() * (1.0 - PT_HOVER_REGION_WIDTH)), + art.y() + qRound(art.height() * (1.0 - PT_HOVER_REGION_HEIGHT)), + qRound(art.width() * PT_HOVER_REGION_WIDTH), qRound(art.height() * PT_HOVER_REGION_HEIGHT)); +} + +bool CardHoverWidget::isCursorOverPTArea() const +{ + if (!ptOverlay || !pic || !imageContainer) { + return false; + } + if (ptOverlay->rect().contains(ptOverlay->mapFromGlobal(QCursor::pos()))) { + return true; + } + return ptHoverRegion().translated(pic->pos()).contains(imageContainer->mapFromGlobal(QCursor::pos())); +} + +QSize CardHoverWidget::measureCompactPTSize() const +{ + if (!ptPillLabel) { + return QSize(PT_PILL_MIN_WIDTH, PT_PILL_MIN_HEIGHT); + } + // QLabel::sizeHint() leaves out the stylesheet padding, so add it back explicitly. + const QSize hint = ptPillLabel->sizeHint(); + return QSize(qMax(PT_PILL_MIN_WIDTH, hint.width() + 2 * PT_PILL_TEXT_PADDING), + qMax(PT_PILL_MIN_HEIGHT, hint.height())); +} + +void CardHoverWidget::resizeEvent(QResizeEvent *event) +{ + QFrame::resizeEvent(event); + positionOverlays(); +} + +void CardHoverWidget::hideEvent(QHideEvent *event) +{ + // A reopened panel for the same card must not come back with the rows still lifted. + setPTExpanded(false); + // The window is already hidden here; repaint whatever it was covering. + invalidateCoveredArea(); + QFrame::hideEvent(event); +} + +// ---------- Display updates ---------- + +void CardHoverWidget::updatePTDisplay() +{ + if (!currentCard) { + ptPowerLabel->clear(); + ptToughLabel->clear(); + ptPillLabel->clear(); + setPTExpanded(false); + return; + } + + QString pt = currentCard->getPT(); + if (pt.isEmpty()) { + ptPowerLabel->setText("--"); + ptToughLabel->setText("--"); + ptPillLabel->setText("--"); + setPTExpanded(false); + } 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("--"); + } + ptPillLabel->setText(pt); + } + + // Three-digit values (100/100, -1/-1) are legal, so the resting pill cannot keep a + // size measured from the seed text: re-measure on every value change and carry the + // width into the expanded stack. + ptCompactSize = measureCompactPTSize(); + applyPTOverlayGeometry(); +} + +// ---------- 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(); + // A new card always starts compact; the rows only lift out while P/T is hovered. + setPTExpanded(false); + 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(); + ptPillLabel->clear(); + setPTExpanded(false); + 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); + invalidateCoveredArea(); +} + +void CardHoverWidget::invalidateCoveredArea() +{ + // The panel is an overlay window: moving or hiding it does not reliably invalidate + // the pixels it uncovered in the game view, which then stay stale (a black square) + // until something else repaints that area. Do it explicitly, unioned with the + // previous placement so a move repaints both sides. + QWidget *viewport = parentWidget(); + if (!viewport) { + lastViewportRect = QRect(); + return; + } + + QRect covered; + if (isVisible()) { + covered = QRect(viewport->mapFromGlobal(geometry().topLeft()), size()); + } + const QRect dirty = lastViewportRect.united(covered); + if (!dirty.isEmpty()) { + viewport->update(dirty.adjusted(-1, -1, 1, 1)); + } + lastViewportRect = covered; +} + +// ---------- 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(); + invalidateCoveredArea(); +} + +void CardHoverWidget::hideAnimated() +{ + if (isClosing()) { + // Repeated hide requests must not restart the fade, or the panel never finishes + // closing while the cursor keeps moving. + return; + } + fadeIn->stop(); + fadeOut->setStartValue(windowOpacity()); + fadeOut->setEndValue(0.0); + fadeOut->start(); +} + +bool CardHoverWidget::isClosing() const +{ + return fadeOut->state() == QAbstractAnimation::Running; +} + +void CardHoverWidget::runModalAction(const std::function &action) +{ + // The panel is a Qt::ToolTip (Qt::Popup | Qt::Sheet): it is always on top and + // holds an input grab. A modal dialog opened while it is still up lands behind + // it and cannot be activated. Ask the scene to dismiss the panel so its hover + // timers stop and the covered card fades back in, then drop the window + // synchronously to release the grab. + emit dismissRequested(); + fadeIn->stop(); + fadeOut->stop(); + hide(); + + // Defer the dialog to the next event-loop turn: the popup grab is not fully + // released until after the current mouse event returns. + QTimer::singleShot(0, this, action); +} + +// ---------- Event filter ---------- + +bool CardHoverWidget::eventFilter(QObject *obj, QEvent *event) +{ + // Handle double-click on name label to start editing annotation. Annotations are set + // by card id on our own zone, so another player's card must not open the editor. + if (obj == nameLabel && event->type() == QEvent::MouseButtonDblClick && canModifyCurrentCard()) { + 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); + } + } + + // The P/T trigger is the printed P/T area, not just the pill: hovering that quadrant of + // the art lifts the control rows out. Driving it from pic's mouse moves (instead of an + // invisible child widget over the picture) keeps click-to-select working underneath. + if (obj == pic) { + if (event->type() == QEvent::MouseMove) { + const auto *me = static_cast(event); + const QPoint pos = me->position().toPoint(); + if (ptOpenRegion().contains(pos)) { + setPTExpanded(true); + } else if (!isCursorOverPTArea()) { + setPTExpanded(false); + } + } else if (event->type() == QEvent::Leave && !isCursorOverPTArea()) { + setPTExpanded(false); + } + } + + // P/T overlay: hovering the P/T area lifts the control rows out of the pill. The + // filter sits on the whole subtree so moving onto a button keeps it expanded. + const auto *ptWidget = qobject_cast(obj); + if (ptWidget && ptOverlay && (ptWidget == ptOverlay || ptOverlay->isAncestorOf(ptWidget))) { + if (event->type() == QEvent::Enter) { + setPTExpanded(true); + } else if (event->type() == QEvent::Leave) { + // Moving onto a child widget fires Leave on the parent too; only collapse once + // the cursor has left both the overlay and the printed P/T area. + if (!isCursorOverPTArea()) { + setPTExpanded(false); + } + } + } + + 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..acd9d4cc6 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_hover_widget.h @@ -0,0 +1,231 @@ +/** + * @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 +#include + +class CardInfoPictureWidget; +class CardInfoTextWidget; +class CardItem; +class CardState; +class PlayerGraphicsItem; +class QVBoxLayout; +class QHBoxLayout; +class QPushButton; +class QToolButton; +class QButtonGroup; +class QStackedWidget; +class QIcon; +class QLabel; +class QLineEdit; +class QEnterEvent; +class QHideEvent; +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 True while the panel is fading out towards a hide. */ + bool isClosing() const; + + /** @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; + void hideEvent(QHideEvent *event) override; + +signals: + void hoverEntered(); + void hoverLeft(); + /** @brief Asks the scene to dismiss the panel before a modal dialog opens. */ + void dismissRequested(); + +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; + + // Viewport area the panel covered at its last placement; used to repaint what the + // overlay window uncovers when it moves or hides. + QRect lastViewportRect; + + // 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 overlay (bottom-right of card image): a compact value pill at rest that + // lifts its control rows out of itself while the P/T area is hovered. + QWidget *ptOverlay; + QWidget *ptControls; + QLabel *ptPillLabel; + bool ptExpanded = false; + QSize ptCompactSize; + QSize ptFullSize; + 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 QIcon &icon, int pageIndex); + void createContentStack(); + QWidget *makeStackPage(QVBoxLayout *&layout); + void showPage(int index); + + /** + * @brief Dismisses the panel, then runs an action that opens a modal dialog. + * + * A Qt::ToolTip window is always on top and holds the input grab, so a modal + * dialog opened from inside the panel lands behind it and cannot be + * activated. Hide the panel synchronously, then defer the action so the grab + * is released before the dialog's event loop starts. + */ + void runModalAction(const std::function &action); + + QLabel *createSectionHeader(const QString &text, QWidget *parent); + QLabel *createNoteLabel(const QString &text, QWidget *parent); + + void connectCardSignals(); + void disconnectCardSignals(); + void scheduleRefresh(); + + void rebuildActionsForZone(); + /** @brief Whether the local player may edit the current card (own card, judge, writable zone). */ + bool canModifyCurrentCard() const; + 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(); + /** @brief Shows/hides the P/T control rows; the value pill stays at the bottom. */ + void setPTExpanded(bool expanded); + /** @brief Anchors the pill on the printed P/T box, or the expanded stack above it. */ + void applyPTOverlayGeometry(); + /** @brief Resting pill geometry, in @c imageContainer coordinates. */ + QRect compactPillRect() const; + /** @brief Pill plus halo: the only area that opens the rows, in @c pic coordinates. */ + QRect ptOpenRegion() const; + /** @brief Bottom-right band the rows stay open in (never opens them), in @c pic coordinates. */ + QRect ptHoverRegion() const; + /** @brief True while the cursor is over the overlay or the printed P/T area. */ + bool isCursorOverPTArea() const; + /** @brief Re-measures the resting pill from its current text. */ + QSize measureCompactPTSize() const; + /** @brief Repaints the viewport area this window covers and the one it just left. */ + void invalidateCoveredArea(); + + void clearLayout(QLayout *layout); + + QPushButton *createOverlayButton(const QString &text, const QString &tooltip, const std::function &slot); + QToolButton *createGridButton(const QString &text, + const QIcon &icon, + 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 diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp index 8f02ebcde..0d0c11fdf 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp @@ -175,6 +175,8 @@ void CardInfoPictureWidget::setScaleFactor(const int scale) void CardInfoPictureWidget::updatePixmap() { pixmapDirty = true; + // The artwork rect belongs to the pixmap that is about to be loaded. + artRectCache = QRect(); update(); } @@ -252,6 +254,24 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event) // Draw the pre-scaled pixmap directly painter.drawPixmap(targetRect, finalPixmap); + + if (targetRect != artRectCache) { + artRectCache = targetRect; + // Overlays anchored on the artwork (rather than the widget) re-position on this. + emit artRectChanged(); + } +} + +/** + * @brief Rect of the drawn artwork inside this widget. + * + * Mirrors the target rect paintEvent() computes: the pixmap scaled with KeepAspectRatio and + * centred, after the optional sideways-layout rotation. Falls back to rect() until the first + * paint, which is the right answer for the usual portrait card. + */ +QRect CardInfoPictureWidget::artRect() const +{ + return artRectCache.isEmpty() ? rect() : artRectCache; } /** diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h index 0eb731d92..5cd7e912a 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h @@ -41,6 +41,14 @@ public: return exactCard; } [[nodiscard]] QSize sizeHint() const override; + /** + * @brief Rect of the drawn artwork inside this widget. + * + * The pixmap is scaled with KeepAspectRatio and centred, and sideways-layout cards may be + * rotated, so this is usually smaller than rect(). Callers that overlay the picture (rather + * than the widget) must anchor on this. Falls back to rect() until the first paint. + */ + [[nodiscard]] QRect artRect() const; /** * @brief The size a card picture has at @p scaleFactor percent. @@ -60,6 +68,8 @@ signals: void cardScaleFactorChanged(int _scale); void cardChanged(const ExactCard &card); void cardClicked(QMouseEvent *event, const ExactCard &card); + /** @brief Emitted when the drawn artwork rect changes (size, scale factor, rotation, card). */ + void artRectChanged(); protected: void resizeEvent(QResizeEvent *event) override; @@ -83,6 +93,8 @@ private: double scaleFactor = 100; QPixmap resizedPixmap; bool pixmapDirty; + /// Last drawn artwork rect, in widget coordinates; empty until the first paint. + QRect artRectCache; bool hoverToZoomEnabled; bool raiseOnEnter; diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp index 128597962..4a9701da2 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp @@ -40,8 +40,10 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent searchEdit->setObjectName("searchEdit"); searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), + QLineEdit::LeadingPosition); + auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info"), IconTint::quiet()), + QLineEdit::TrailingPosition); setFocusProxy(searchEdit); setFocusPolicy(Qt::ClickFocus); @@ -83,7 +85,8 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent &DeckEditorDatabaseDisplayWidget::onRelatedCardClicked); aAddCard = new QAction(QString(), this); - aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"), AppColor::Positive)); + aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"), + IconTint(AppColor::Positive, IconEmphasis::Primary), IconStyle::Sticker)); connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck); auto *tbAddCard = new QToolButton(this); tbAddCard->setDefaultAction(aAddCard); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp index 33a9cd63c..6e4701f53 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp @@ -68,7 +68,8 @@ void DeckEditorFilterDockWidget::createFiltersDock() connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne); aClearFilterAll = new QAction(QString(), this); - aClearFilterAll->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); + aClearFilterAll->setIcon( + loadThemedIcon(QStringLiteral("icons/clearsearch"), AppColor::Negative, IconStyle::Sticker)); connect(aClearFilterAll, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterAll); auto *filterDelOne = new QToolButton(); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp index c6e640a9f..cf487d930 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp @@ -89,7 +89,8 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) searchField = new LineEditUnfocusable; searchField->setObjectName("searchEdit"); searchField->setPlaceholderText(tr("Search by set name, code, type, or release date")); - searchField->addAction(loadThemedIcon(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition); + searchField->addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), + LineEditUnfocusable::LeadingPosition); searchField->setClearButtonEnabled(true); setFocusProxy(searchField); diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index b1a80c86b..8d52ff56a 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -135,7 +135,8 @@ void SettingsView::setupUi() // Search bar searchEdit = new QLineEdit; searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), + QLineEdit::LeadingPosition); searchEdit->installEventFilter(this); connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged); searchEdit->setObjectName("settingsSearchEdit"); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp index a5177a6bc..f7975fb81 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp @@ -430,7 +430,8 @@ static QPixmap composePinBadgePixmap(const QSize &logicalSize, qreal devicePixel // Inset the glyph so the backdrop reads as a badge around it, not as a border. const int inset = qRound(logicalSize.width() * 0.16); const QSize glyphSize = logicalSize - QSize(inset * 2, inset * 2); - const QPixmap glyph = loadThemedIcon(QStringLiteral("icons/pin")).pixmap(glyphSize, devicePixelRatio); + const QPixmap glyph = + loadThemedIcon(QStringLiteral("icons/pin"), AppColor::Marked).pixmap(glyphSize, devicePixelRatio); if (glyph.isNull()) { // Let the caller fall back to the textual badge instead of drawing an empty panel. return QPixmap(); diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp index 44ce01aba..b29c4d90c 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp @@ -28,6 +28,14 @@ enum visualDeckStoragePromptForConversionIndex visualDeckStoragePromptForConversionIndexAlways }; +// A corrupt or out-of-range stored trigger would otherwise select index -1 and leave +// the row half-hidden; clamp it back into the known trigger range. +static int clampHoverTrigger(int trigger) +{ + return qBound(static_cast(CardsDisplaySettings::HoverPanelTriggerHover), trigger, + static_cast(CardsDisplaySettings::HoverPanelTriggerOff)); +} + UserInterfaceSettingsPage::UserInterfaceSettingsPage() { // general settings @@ -154,6 +162,68 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() animationGroupBox = new QGroupBox; animationGroupBox->setLayout(animationGrid); + // hover panel settings + hoverZoomTriggerSelector.addItem(QString()); + hoverZoomTriggerSelector.addItem(QString()); + hoverZoomTriggerSelector.addItem(QString()); + hoverZoomTriggerSelector.addItem(QString()); + hoverZoomTriggerSelector.addItem(QString()); + hoverZoomTriggerSelector.setCurrentIndex( + clampHoverTrigger(SettingsCache::instance().cardsDisplay().getHoverZoomTrigger())); + connect(&hoverZoomTriggerSelector, &QComboBox::currentIndexChanged, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setHoverZoomTrigger); + + hoverPanelTriggerSelector.addItem(QString()); + hoverPanelTriggerSelector.addItem(QString()); + hoverPanelTriggerSelector.addItem(QString()); + hoverPanelTriggerSelector.addItem(QString()); + hoverPanelTriggerSelector.addItem(QString()); + hoverPanelTriggerSelector.setCurrentIndex( + clampHoverTrigger(SettingsCache::instance().cardsDisplay().getHoverPanelTrigger())); + connect(&hoverPanelTriggerSelector, &QComboBox::currentIndexChanged, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setHoverPanelTrigger); + + hoverPanelDoubleConfirmCheckBox.setChecked(SettingsCache::instance().cardsDisplay().getHoverPanelDoubleConfirm()); + connect(&hoverPanelDoubleConfirmCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setHoverPanelDoubleConfirm); + + hoverZoomDelayBox.setRange(0, 5000); + hoverZoomDelayBox.setSingleStep(50); + hoverZoomDelayBox.setSuffix(" ms"); + hoverZoomDelayBox.setValue(SettingsCache::instance().cardsDisplay().getHoverZoomDelayMs()); + connect(&hoverZoomDelayBox, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setHoverZoomDelayMs); + + hoverPanelDelayBox.setRange(0, 5000); + hoverPanelDelayBox.setSingleStep(50); + hoverPanelDelayBox.setSuffix(" ms"); + hoverPanelDelayBox.setValue(SettingsCache::instance().cardsDisplay().getHoverPanelDelayMs()); + connect(&hoverPanelDelayBox, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setHoverPanelDelayMs); + + auto *hoverPanelGrid = new QGridLayout; + hoverPanelGrid->addWidget(&hoverZoomTriggerLabel, 0, 0); + hoverPanelGrid->addWidget(&hoverZoomTriggerSelector, 0, 1); + hoverPanelGrid->addWidget(&hoverPanelTriggerLabel, 1, 0); + hoverPanelGrid->addWidget(&hoverPanelTriggerSelector, 1, 1); + hoverPanelGrid->addWidget(&hoverPanelDoubleConfirmCheckBox, 2, 0, 1, 2); + hoverPanelGrid->addWidget(&hoverZoomDelayLabel, 3, 0); + hoverPanelGrid->addWidget(&hoverZoomDelayBox, 3, 1); + hoverPanelGrid->addWidget(&hoverPanelDelayLabel, 4, 0); + hoverPanelGrid->addWidget(&hoverPanelDelayBox, 4, 1); + hoverWheelNoteLabel.setWordWrap(true); + hoverPanelGrid->addWidget(&hoverWheelNoteLabel, 5, 0, 1, 2); + connect(&hoverZoomTriggerSelector, &QComboBox::currentIndexChanged, this, + &UserInterfaceSettingsPage::updateHoverPanelUiState); + connect(&hoverPanelTriggerSelector, &QComboBox::currentIndexChanged, this, + &UserInterfaceSettingsPage::updateHoverPanelUiState); + connect(&hoverPanelDoubleConfirmCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &UserInterfaceSettingsPage::updateHoverPanelUiState); + updateHoverPanelUiState(); + + hoverPanelGroupBox = new QGroupBox; + hoverPanelGroupBox->setLayout(hoverPanelGrid); + // deck editor settings openDeckInNewTabCheckBox.setChecked(SettingsCache::instance().deckEditor().getOpenDeckInNewTab()); connect(&openDeckInNewTabCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().deckEditor(), @@ -293,6 +363,7 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() mainLayout->addWidget(generalGroupBox); mainLayout->addWidget(notificationsGroupBox); mainLayout->addWidget(animationGroupBox); + mainLayout->addWidget(hoverPanelGroupBox); mainLayout->addWidget(deckEditorGroupBox); mainLayout->addWidget(replayGroupBox); mainLayout->addStretch(); @@ -351,6 +422,33 @@ void UserInterfaceSettingsPage::updateCommanderSpellbookUiState() } } +void UserInterfaceSettingsPage::updateHoverPanelUiState() +{ + // Phase timers only apply to the hover trigger; double-confirm only applies when + // both phases share one trigger (repeating it). + const auto hoverTrigger = CardsDisplaySettings::HoverPanelTriggerHover; + const auto wheelTrigger = CardsDisplaySettings::HoverPanelTriggerWheel; + const auto offTrigger = CardsDisplaySettings::HoverPanelTriggerOff; + const bool zoomOnHover = hoverZoomTriggerSelector.currentIndex() == hoverTrigger; + const bool panelOnHover = hoverPanelTriggerSelector.currentIndex() == hoverTrigger; + const bool anyOff = + hoverZoomTriggerSelector.currentIndex() == offTrigger || hoverPanelTriggerSelector.currentIndex() == offTrigger; + const bool sharedTrigger = + !anyOff && hoverZoomTriggerSelector.currentIndex() == hoverPanelTriggerSelector.currentIndex(); + hoverZoomDelayLabel.setVisible(zoomOnHover); + hoverZoomDelayBox.setVisible(zoomOnHover); + hoverPanelDelayLabel.setVisible(panelOnHover); + hoverPanelDelayBox.setVisible(panelOnHover); + hoverPanelDoubleConfirmCheckBox.setEnabled(sharedTrigger); + const bool panelAuto = panelOnHover && !(sharedTrigger && hoverPanelDoubleConfirmCheckBox.isChecked()); + hoverPanelDelayLabel.setEnabled(panelAuto); + hoverPanelDelayBox.setEnabled(panelAuto); + // The wheel trigger consumes wheel events over cards, so the board cannot scroll there. + const bool wheelBound = hoverZoomTriggerSelector.currentIndex() == wheelTrigger || + hoverPanelTriggerSelector.currentIndex() == wheelTrigger; + hoverWheelNoteLabel.setVisible(wheelBound); +} + void UserInterfaceSettingsPage::retranslateUi() { generalGroupBox->setTitle(tr("General interface settings")); @@ -380,6 +478,22 @@ void UserInterfaceSettingsPage::retranslateUi() lifeCounterAnimationsCheckBox.setText(tr("Life counter flash")); battlefieldFlashCheckBox.setText(tr("Battlefield flash on damage")); + hoverPanelGroupBox->setTitle(tr("Hover panel settings")); + hoverPanelTriggerLabel.setText(tr("Show the card panel on:")); + hoverZoomTriggerLabel.setText(tr("Zoom the card on:")); + for (auto *selector : {&hoverPanelTriggerSelector, &hoverZoomTriggerSelector}) { + selector->setItemText(CardsDisplaySettings::HoverPanelTriggerHover, tr("Hover")); + selector->setItemText(CardsDisplaySettings::HoverPanelTriggerRightClick, tr("Right-click")); + selector->setItemText(CardsDisplaySettings::HoverPanelTriggerWheel, tr("Mouse wheel")); + selector->setItemText(CardsDisplaySettings::HoverPanelTriggerShortcut, tr("Keyboard shortcut")); + selector->setItemText(CardsDisplaySettings::HoverPanelTriggerOff, tr("Off")); + } + hoverPanelDoubleConfirmCheckBox.setText(tr("Zoom first; repeat the action to show the panel")); + hoverZoomDelayLabel.setText(tr("Time before the zoom kicks in:")); + hoverPanelDelayLabel.setText(tr("Time before the tab panel shows up:")); + hoverWheelNoteLabel.setText( + tr("Note: the mouse wheel action replaces board scrolling over cards; scroll up to zoom back out.")); + deckEditorGroupBox->setTitle(tr("Deck editor/storage settings")); openDeckInNewTabCheckBox.setText(tr("Open deck in new tab by default")); visualDeckEditorShowCardCountsCheckBox.setText(tr("Show card counts in Visual Deck Editor")); diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h index 297d80c35..6b60c58bf 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h @@ -22,6 +22,7 @@ private slots: void enableAllAnimations(); void disableAllAnimations(); void updateCommanderSpellbookUiState(); + void updateHoverPanelUiState(); private: QCheckBox doubleClickToPlayCheckBox; @@ -47,6 +48,17 @@ private: QCheckBox lifeCounterAnimationsCheckBox; QCheckBox battlefieldFlashCheckBox; + QLabel hoverPanelTriggerLabel; + QComboBox hoverPanelTriggerSelector; + QLabel hoverZoomTriggerLabel; + QComboBox hoverZoomTriggerSelector; + QCheckBox hoverPanelDoubleConfirmCheckBox; + QLabel hoverZoomDelayLabel; + QSpinBox hoverZoomDelayBox; + QLabel hoverPanelDelayLabel; + QSpinBox hoverPanelDelayBox; + QLabel hoverWheelNoteLabel; + QCheckBox openDeckInNewTabCheckBox; QLabel visualDeckStoragePromptForConversionLabel; QComboBox visualDeckStoragePromptForConversionSelector; @@ -69,6 +81,7 @@ private: QGroupBox *generalGroupBox; QGroupBox *notificationsGroupBox; QGroupBox *animationGroupBox; + QGroupBox *hoverPanelGroupBox; QGroupBox *deckEditorGroupBox; QGroupBox *replayGroupBox; diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 9e031c79a..32abde046 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -223,7 +223,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); aShareDecks = new QAction(this); - aShareDecks->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); + aShareDecks->setIcon(loadThemedIcon(QStringLiteral("icons/share"), AppColor::Informational)); connect(aShareDecks, &QAction::triggered, this, &TabDeckStorage::actShareDecks); aPublishDeck = new QAction(this); diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp index 3868ee75f..e33f1754e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp @@ -222,7 +222,7 @@ QGroupBox *TabReplays::createRightLayout() loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay); aGetReplayCode = new QAction(this); - aGetReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); + aGetReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/share"), AppColor::Informational)); connect(aGetReplayCode, &QAction::triggered, this, &TabReplays::actGetReplayCode); aSubmitReplayCode = new QAction(this); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp index f109b25c1..f4c4f7c4e 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp @@ -46,13 +46,15 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg sortGroupBox = new QGroupBox(this); filterGroupBox = new QGroupBox(this); - auto scalePixmap = [](const QString &prefix) { return loadThemedIcon(prefix).pixmap({20, 20}); }; + auto scalePixmap = [](const QString &prefix, const IconTint &tint = {}) { + return loadThemedIcon(prefix, tint).pixmap({20, 20}); + }; sortByLabel = new QLabel(this); - sortByLabel->setPixmap(scalePixmap("icons/sort_arrow_down")); + sortByLabel->setPixmap(scalePixmap("icons/sort_arrow_down", AppColor::Informational)); filterByLabel = new QLabel(this); - filterByLabel->setPixmap(scalePixmap("icons/filter")); + filterByLabel->setPixmap(scalePixmap("icons/filter", AppColor::Informational)); sortColumnCombo = new QComboBox(this); sortColumnCombo->setSizeAdjustPolicy(QComboBox::SizeAdjustPolicy::AdjustToContents); @@ -90,22 +92,23 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg }); quickFilterSaveLoadWidget = new SettingsButtonWidget(this); - quickFilterSaveLoadWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/floppy_disk"))); + quickFilterSaveLoadWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/floppy_disk"), IconTint::primary())); quickFilterNameWidget = new SettingsButtonWidget(this); - quickFilterNameWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/pen_to_square"))); + quickFilterNameWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/pen_to_square"), IconTint::primary())); quickFilterMainTypeWidget = new SettingsButtonWidget(this); - quickFilterMainTypeWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/circle_half_stroke"))); + quickFilterMainTypeWidget->setButtonIcon( + loadThemedIcon(QStringLiteral("icons/circle_half_stroke"), IconTint::primary())); quickFilterSubTypeWidget = new SettingsButtonWidget(this); - quickFilterSubTypeWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/dragon"))); + quickFilterSubTypeWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/dragon"), IconTint::primary())); quickFilterSetWidget = new SettingsButtonWidget(this); - quickFilterSetWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"))); + quickFilterSetWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"), IconTint::primary())); quickFilterFormatLegalityWidget = new SettingsButtonWidget(this); - quickFilterFormatLegalityWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); + quickFilterFormatLegalityWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"), IconTint::primary())); quickFilterTagWidget = new SettingsButtonWidget(this); quickFilterTagWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/tags"))); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 7f516839a..61cdfefe4 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -81,8 +81,10 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, searchEdit->setObjectName("searchEdit"); searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), + QLineEdit::LeadingPosition); + auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info"), AppColor::Informational), + QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(searchEdit); }); setFocusProxy(searchEdit); @@ -156,7 +158,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, clearFilterWidget = new QToolButton(); clearFilterWidget->setFixedSize(32, 32); - clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete"), AppColor::Negative)); + clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete"), AppColor::Negative, IconStyle::Sticker)); connect(clearFilterWidget, &QToolButton::clicked, this, [this] { filterModel->blockSignals(true); filterModel->filterTree()->blockSignals(true); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp index f0c054c3a..e2c853d6a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp @@ -119,11 +119,11 @@ void VisualDeckDisplayOptionsWidget::updateDisplayTypeButton() switch (currentDisplayType) { case DisplayType::Flat: displayTypeButton->setButtonText(tr("Toggle Layout: Flat")); - displayTypeButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"))); + displayTypeButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"), IconTint::primary())); break; case DisplayType::Overlap: displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); + displayTypeButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"), IconTint::primary())); break; } } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp index 10a3bf33a..052f6231a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp @@ -27,9 +27,10 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) : searchBar = new QLineEdit(this); searchBar->setPlaceholderText(tr("Search by filename (or search expression)")); searchBar->setClearButtonEnabled(true); - searchBar->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + searchBar->addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), QLineEdit::LeadingPosition); - auto help = searchBar->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + auto help = searchBar->addAction(loadThemedIcon(QStringLiteral("icons/info"), IconTint::quiet()), + QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createDeckSearchSyntaxHelpWindow(searchBar); }); layout->addWidget(searchBar); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index bbf68adb9..14f3c19de 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -67,7 +67,7 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare connect(refreshButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::refreshIfPossible); shareButton = new QToolButton(this); - shareButton->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); + shareButton->setIcon(loadThemedIcon(QStringLiteral("icons/share"), AppColor::Informational)); shareButton->setFixedSize(32, 32); shareButton->setVisible(false); connect(shareButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::shareRequested); diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h index a5ab4fc37..8eb81d2bc 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h @@ -19,6 +19,11 @@ public: [[nodiscard]] virtual bool getArrowDrawAnimation() const = 0; [[nodiscard]] virtual bool getAutoRotateSidewaysLayoutCards() const = 0; [[nodiscard]] virtual bool getScaleCards() const = 0; + [[nodiscard]] virtual int getHoverPanelTrigger() const = 0; + [[nodiscard]] virtual int getHoverZoomTrigger() const = 0; + [[nodiscard]] virtual bool getHoverPanelDoubleConfirm() const = 0; + [[nodiscard]] virtual int getHoverZoomDelayMs() const = 0; + [[nodiscard]] virtual int getHoverPanelDelayMs() const = 0; [[nodiscard]] virtual int getStackCardOverlapPercent() const = 0; [[nodiscard]] virtual int getCardInfoViewMode() const = 0; [[nodiscard]] virtual int getVisualDeckStorageCardSize() const = 0; diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp index f047973f0..910e3f09d 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp @@ -68,6 +68,35 @@ bool CardsDisplaySettings::getScaleCards() const return getValue("scaleCards", QString(), QString(), true).toBool(); } +int CardsDisplaySettings::getHoverPanelTrigger() const +{ + // Off by default: the panel is new, so it is opt-in. + return getValue("hoverPanelTrigger", QString(), QString(), static_cast(HoverPanelTriggerOff)).toInt(); +} + +int CardsDisplaySettings::getHoverZoomTrigger() const +{ + // Off by default: the smooth hover zoom is opt-in. With no trigger configured, + // hovering keeps the historical 1.1x scale (AbstractCardItem::setHoverHighlighted) + // instead of the smooth zoom to the hover panel size. + return getValue("hoverZoomTrigger", QString(), QString(), static_cast(HoverPanelTriggerOff)).toInt(); +} + +bool CardsDisplaySettings::getHoverPanelDoubleConfirm() const +{ + return getValue("hoverPanelDoubleConfirm", QString(), QString(), false).toBool(); +} + +int CardsDisplaySettings::getHoverZoomDelayMs() const +{ + return getValue("hoverZoomDelayMs", QString(), QString(), 400).toInt(); +} + +int CardsDisplaySettings::getHoverPanelDelayMs() const +{ + return getValue("hoverPanelDelayMs", QString(), QString(), 200).toInt(); +} + int CardsDisplaySettings::getStackCardOverlapPercent() const { return getValue("verticalCardOverlapPercent", QString(), QString(), 33).toInt(); @@ -188,6 +217,36 @@ void CardsDisplaySettings::setCardScaling(bool _scaleCards) setValue(_scaleCards, "scaleCards"); } +void CardsDisplaySettings::setHoverPanelTrigger(int _trigger) +{ + setValue(_trigger, "hoverPanelTrigger"); + emit hoverPanelTriggerChanged(_trigger); +} + +void CardsDisplaySettings::setHoverZoomTrigger(int _trigger) +{ + setValue(_trigger, "hoverZoomTrigger"); + emit hoverZoomTriggerChanged(_trigger); +} + +void CardsDisplaySettings::setHoverPanelDoubleConfirm(bool _doubleConfirm) +{ + setValue(_doubleConfirm, "hoverPanelDoubleConfirm"); + emit hoverPanelDoubleConfirmChanged(_doubleConfirm); +} + +void CardsDisplaySettings::setHoverZoomDelayMs(int _ms) +{ + setValue(_ms, "hoverZoomDelayMs"); + emit hoverZoomDelayMsChanged(_ms); +} + +void CardsDisplaySettings::setHoverPanelDelayMs(int _ms) +{ + setValue(_ms, "hoverPanelDelayMs"); + emit hoverPanelDelayMsChanged(_ms); +} + void CardsDisplaySettings::setStackCardOverlapPercent(int _verticalCardOverlapPercent) { setValue(_verticalCardOverlapPercent, "verticalCardOverlapPercent"); diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h index 961550d3c..59a513e8f 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h @@ -15,6 +15,18 @@ class CardsDisplaySettings : public SettingsManager, public ICardsDisplaySetting friend class SettingsCache; public: + /// What can summon the hover zoom or panel. Stored as int; values are the + /// serialization contract, so never reorder or renumber. + enum HoverPanelTrigger + { + HoverPanelTriggerHover = 0, + HoverPanelTriggerRightClick = 1, + HoverPanelTriggerWheel = 2, + HoverPanelTriggerShortcut = 3, + /// Never fires. The hover zoom and panel are opt-in, so this is their default. + HoverPanelTriggerOff = 4 + }; + [[nodiscard]] bool getDisplayCardNames() const override; [[nodiscard]] bool getRoundCardCorners() const override; [[nodiscard]] bool getOverrideAllCardArtWithPersonalPreference() const override; @@ -26,6 +38,16 @@ public: [[nodiscard]] bool getArrowDrawAnimation() const override; [[nodiscard]] bool getAutoRotateSidewaysLayoutCards() const override; [[nodiscard]] bool getScaleCards() const override; + /// What summons the in-game card hover panel (a HoverPanelTrigger value). + [[nodiscard]] int getHoverPanelTrigger() const override; + /// What zooms the hovered card (a HoverPanelTrigger value). Zoom and panel triggers are independent. + [[nodiscard]] int getHoverZoomTrigger() const override; + /// When true, the trigger first only zooms; repeating it shows the panel. + [[nodiscard]] bool getHoverPanelDoubleConfirm() const override; + /// Hover dwell before the zoom kicks in (ms). Only used with the hover trigger. + [[nodiscard]] int getHoverZoomDelayMs() const override; + /// Wait after the zoom before the tab panel shows (ms). Hover trigger, one-step only. + [[nodiscard]] int getHoverPanelDelayMs() const override; [[nodiscard]] int getStackCardOverlapPercent() const override; [[nodiscard]] int getCardInfoViewMode() const override; [[nodiscard]] int getVisualDeckStorageCardSize() const override; @@ -49,6 +71,11 @@ public: void setArrowDrawAnimation(bool _arrowDrawAnimation); void setAutoRotateSidewaysLayoutCards(bool _autoRotateSidewaysLayoutCards); void setCardScaling(bool _scaleCards); + void setHoverPanelTrigger(int _trigger); + void setHoverZoomTrigger(int _trigger); + void setHoverPanelDoubleConfirm(bool _doubleConfirm); + void setHoverZoomDelayMs(int _ms); + void setHoverPanelDelayMs(int _ms); void setStackCardOverlapPercent(int _verticalCardOverlapPercent); void setCardInfoViewMode(int _viewMode); void setVisualDeckStorageCardSize(int _cardSize); @@ -78,6 +105,11 @@ signals: void sampleHandSizeChanged(int amount); void cardLangChanged(const QString &lang); void cardSearchLanguageChanged(int cardSearchLanguage); + void hoverPanelTriggerChanged(int trigger); + void hoverZoomTriggerChanged(int trigger); + void hoverPanelDoubleConfirmChanged(bool doubleConfirm); + void hoverZoomDelayMsChanged(int ms); + void hoverPanelDelayMsChanged(int ms); public: explicit CardsDisplaySettings(const QString &settingPath, QObject *parent = nullptr); diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index aea7f1d54..4ca440567 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -683,6 +683,77 @@ TEST_F(SettingsDefaultsTest, CardsDisplay_ArrowDrawAnimation_Default) ASSERT_EQ(s.getArrowDrawAnimation(), true); } +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelTrigger_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + // Off by default: the panel is opt-in on upgrade. + ASSERT_EQ(s.getHoverPanelTrigger(), CardsDisplaySettings::HoverPanelTriggerOff); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelTrigger_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setHoverPanelTrigger(2); + ASSERT_EQ(s.getHoverPanelTrigger(), 2); + s.setHoverPanelTrigger(0); + ASSERT_EQ(s.getHoverPanelTrigger(), 0); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverZoomTrigger_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + // Off by default: the smooth hover zoom is opt-in on upgrade. + ASSERT_EQ(s.getHoverZoomTrigger(), CardsDisplaySettings::HoverPanelTriggerOff); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverZoomTrigger_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setHoverZoomTrigger(1); + ASSERT_EQ(s.getHoverZoomTrigger(), 1); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelDoubleConfirm_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHoverPanelDoubleConfirm(), false); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelDoubleConfirm_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setHoverPanelDoubleConfirm(true); + ASSERT_EQ(s.getHoverPanelDoubleConfirm(), true); + s.setHoverPanelDoubleConfirm(false); + ASSERT_EQ(s.getHoverPanelDoubleConfirm(), false); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverZoomDelayMs_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHoverZoomDelayMs(), 400); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverZoomDelayMs_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setHoverZoomDelayMs(750); + ASSERT_EQ(s.getHoverZoomDelayMs(), 750); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelDelayMs_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHoverPanelDelayMs(), 200); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelDelayMs_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setHoverPanelDelayMs(0); + ASSERT_EQ(s.getHoverPanelDelayMs(), 0); +} + TEST_F(SettingsDefaultsTest, CardsDisplay_CardLang_Default) { CardsDisplaySettings s(settingsPath, nullptr); diff --git a/tests/theme_migration/icon_tint_preview_test.cpp b/tests/theme_migration/icon_tint_preview_test.cpp index b4244cef0..b5b57f7b2 100644 --- a/tests/theme_migration/icon_tint_preview_test.cpp +++ b/tests/theme_migration/icon_tint_preview_test.cpp @@ -68,10 +68,12 @@ template QPixmap renderAtDpr(const QSize &logicalSize, qreal d * @brief Writes one semantic-tint sheet per shipped palette theme. * * The theme files are the only place the shipped tones live, so each sheet is - * rendered from the palette the theme actually ships: both semantic families, - * three glyphs each, at the two sizes the UI uses, on the window and button - * surfaces the contrast is measured against. A disabled row closes each family - * so the enabled/disabled order is visible next to the tone that carries it. + * rendered from the palette the theme actually ships: all three semantic + * families, three glyphs each, at the two sizes the UI uses, on the window and + * button surfaces the contrast is measured against. A disabled row closes each + * family so the enabled/disabled order is visible next to the tone that carries + * it. A theme that does not author Informational falls back to the scheme + * default, which is what the client paints. */ TEST(IconTintPreview, WritesOneSheetPerShippedTheme) { @@ -100,6 +102,10 @@ TEST(IconTintPreview, WritesOneSheetPerShippedTheme) const QList families = { {"Positive", AppColor::Positive, {"increment", "ready_start", "arrow_up"}}, {"Negative", AppColor::Negative, {"decrement", "not_ready_start", "remove_row"}}, + {"Informational", AppColor::Informational, {"arrow_right", "view", "arrow_to_top"}}, + {"Hidden", AppColor::Hidden, {"view", "arrow_right", "swap"}}, + {"Marked", AppColor::Marked, {"pencil", "share", "pin"}}, + {"Movement", AppColor::Movement, {"arrow_to_top", "arrow_to_bottom", "arrow_down"}}, }; const QStringList themes = { QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), diff --git a/tests/theme_migration/icon_treatment_preview_test.cpp b/tests/theme_migration/icon_treatment_preview_test.cpp index 1d027a9e7..0af177560 100644 --- a/tests/theme_migration/icon_treatment_preview_test.cpp +++ b/tests/theme_migration/icon_treatment_preview_test.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -69,7 +70,7 @@ template QPixmap renderAtDpr(const QSize &logicalSize, qreal d * @brief Writes one badge/sticker sheet per shipped palette theme. * * The treatments are only useful if they read on every palette, so each sheet - * is rendered from the palette the theme actually ships: both semantic + * is rendered from the palette the theme actually ships: all three semantic * families, three glyphs each, as line glyphs, stickers and badges, at the two * sizes the UI uses, on the window and button surfaces the contrast is measured * against. A disabled row closes each family: every treatment falls back to the @@ -102,9 +103,17 @@ TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme) AppColor::Role role; const char *glyphs[3]; }; + // The hover panel's action grid is the first call site that puts a + // treatment on an action glyph, so the sheet renders the glyphs that grid + // actually uses: play/clone/hand (Positive), reduce life and graveyard + // (Negative), place face down and exile (Informational). const QList families = { - {"Positive", AppColor::Positive, {"increment", "ready_start", "arrow_up"}}, - {"Negative", AppColor::Negative, {"decrement", "not_ready_start", "remove_row"}}, + {"Positive", AppColor::Positive, {"arrow_right", "increment", "arrow_left"}}, + {"Negative", AppColor::Negative, {"arrow_down", "decrement", "remove_row"}}, + {"Informational", AppColor::Informational, {"arrow_right", "view", "arrow_to_top"}}, + {"Hidden", AppColor::Hidden, {"view", "arrow_right", "swap"}}, + {"Marked", AppColor::Marked, {"pencil", "share", "pin"}}, + {"Movement", AppColor::Movement, {"arrow_to_top", "arrow_to_bottom", "arrow_down"}}, }; const QStringList themes = { QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), @@ -246,3 +255,312 @@ TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme) } } } + +namespace +{ +/** @brief One action-grid cell, with the role the panel hands the icon engine. */ +struct PreviewAction +{ + QString label; + const char *glyph; + AppColor::Role role; +}; + +/** @brief One titled section of a hover panel page. */ +struct PreviewSection +{ + QString title; + QList actions; +}; + +/** @brief One hover panel page, as the client builds it. */ +struct PreviewPage +{ + QString title; + QList sections; +}; + +/** @brief Renders a sentinel SVG in one ink at the requested pixel size. */ +QPixmap renderInkGlyph(const QByteArray &svg, const QColor &ink, const QSize &size) +{ + QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; +} +} // namespace + +/** + * @brief Writes the hover panel's action grids the way the client paints them. + * + * The treatment sheets show glyphs in isolation; this renders the real layout — + * section headers and two-column cells with the glyph above its label — so the + * emphasis split (quiet tabs, primary grid) and the semantic map can be judged + * as the set the player actually sees. The roles are the ones the panel passes + * to the icon engine, already resolved to the emphasis level it requests, and a + * role the theme does not author resolves through the same scheme default the + * client paints. + */ +TEST(IconTreatmentPreview, HoverPanelGridWritesPngsWhenRequested) +{ + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ensureGuiApplication(); + ASSERT_TRUE(QDir().mkpath(outDir)); + + constexpr int MARGIN = 24; + constexpr int COLUMNS = 2; + constexpr int CELL_WIDTH = 146; + constexpr int CELL_HEIGHT = 62; + constexpr int SECTION_HEIGHT = 22; + constexpr int PANEL_PADDING = 10; + constexpr int PANEL_TITLE = 24; + constexpr int GLYPH = 22; + constexpr qreal DPR = 2.0; + + // The pages a selected card shows, with every conditional action present + // so the whole map is visible at once. + const QList pages = { + PreviewPage{.title = QStringLiteral("Main — on the battlefield"), + .sections = + { + PreviewSection{.title = QStringLiteral("Battlefield"), + .actions = + { + PreviewAction{.label = QStringLiteral("Tap / Untap"), + .glyph = "sync", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Skip untapping"), + .glyph = "lock", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Clone"), + .glyph = "increment", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Attach to card..."), + .glyph = "pin", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Draw arrow..."), + .glyph = "share", + .role = AppColor::MarkedStrong}, + PreviewAction{.label = QStringLiteral("Set annotation..."), + .glyph = "pencil", + .role = AppColor::MarkedStrong}, + PreviewAction{.label = QStringLiteral("Turn Over"), + .glyph = "swap", + .role = AppColor::HiddenStrong}, + PreviewAction{.label = QStringLiteral("Peek at card face"), + .glyph = "view", + .role = AppColor::HiddenStrong}, + PreviewAction{.label = QStringLiteral("Unattach"), + .glyph = "arrow_undo", + .role = AppColor::NegativeStrong}, + PreviewAction{.label = QStringLiteral("Reduce life"), + .glyph = "arrow_down", + .role = AppColor::NegativeStrong}, + }}, + PreviewSection{.title = QStringLiteral("Select"), + .actions = + { + PreviewAction{.label = QStringLiteral("Select All"), + .glyph = "search", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Select Row"), + .glyph = "filter", + .role = AppColor::InformationalStrong}, + }}, + }}, + PreviewPage{.title = QStringLiteral("Main — in a zone"), + .sections = + { + PreviewSection{.title = QStringLiteral("Zone"), + .actions = + { + PreviewAction{.label = QStringLiteral("Play"), + .glyph = "arrow_right", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Play Face Down"), + .glyph = "arrow_right", + .role = AppColor::HiddenStrong}, + PreviewAction{.label = QStringLiteral("Clone"), + .glyph = "increment", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Attach to card..."), + .glyph = "pin", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Draw arrow..."), + .glyph = "share", + .role = AppColor::MarkedStrong}, + }}, + PreviewSection{.title = QStringLiteral("Select"), + .actions = + { + PreviewAction{.label = QStringLiteral("Select All"), + .glyph = "search", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Select Column"), + .glyph = "filter", + .role = AppColor::InformationalStrong}, + }}, + }}, + PreviewPage{.title = QStringLiteral("Move — card in a zone"), + .sections = + { + PreviewSection{.title = QStringLiteral("Move to"), + .actions = + { + PreviewAction{.label = QStringLiteral("Top of library"), + .glyph = "arrow_to_top", + .role = AppColor::MovementStrong}, + PreviewAction{.label = QStringLiteral("X from top..."), + .glyph = "search", + .role = AppColor::MovementStrong}, + PreviewAction{.label = QStringLiteral("Bottom of library"), + .glyph = "arrow_to_bottom", + .role = AppColor::MovementStrong}, + PreviewAction{.label = QStringLiteral("Table"), + .glyph = "player", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Hand"), + .glyph = "arrow_left", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Graveyard"), + .glyph = "arrow_down", + .role = AppColor::NegativeStrong}, + PreviewAction{.label = QStringLiteral("Exile"), + .glyph = "arrow_right", + .role = AppColor::InformationalStrong}, + }}, + PreviewSection{.title = QStringLiteral("Reveal to..."), + .actions = + { + PreviewAction{.label = QStringLiteral("All players"), + .glyph = "share", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Alice"), + .glyph = "player", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Bob"), + .glyph = "player", + .role = AppColor::InformationalStrong}, + }}, + }}, + }; + + const auto rowsIn = [](const PreviewSection §ion) { return (section.actions.size() + COLUMNS - 1) / COLUMNS; }; + const auto panelHeightFor = [&](const PreviewPage &page) { + int height = PANEL_TITLE + PANEL_PADDING; + for (const PreviewSection §ion : page.sections) { + height += SECTION_HEIGHT + rowsIn(section) * CELL_HEIGHT; + } + return height + PANEL_PADDING; + }; + + const int panelWidth = COLUMNS * CELL_WIDTH + 2 * PANEL_PADDING; + const int pageCount = static_cast(pages.size()); + int tallestPanel = 0; + for (const PreviewPage &page : pages) { + tallestPanel = qMax(tallestPanel, panelHeightFor(page)); + } + const int logicalWidth = 2 * MARGIN + pageCount * panelWidth + (pageCount - 1) * MARGIN; + const int logicalHeight = MARGIN + 30 + tallestPanel + MARGIN; + + const QString theme = QStringLiteral("Fabric"); + for (const bool dark : {false, true}) { + const QString file = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + const PaletteConfig config = PaletteConfig::fromFile(file); + ASSERT_TRUE(config.hasPalette()) << theme.toStdString(); + + const QPalette palette = config.apply(QPalette()); + const QColor window = palette.color(QPalette::Active, QPalette::Window); + const QColor text = palette.color(QPalette::Active, QPalette::WindowText); + // The panel's cells sit on the popup surface; painting them on the + // button color is what a QToolButton shows when it is not hovered. + const QColor cell = palette.color(QPalette::Active, QPalette::Button); + + QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), + QImage::Format_ARGB32_Premultiplied); + image.fill(window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, MARGIN), + QStringLiteral("%1 — %2 scheme, hover panel action grids") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + + const auto toneFor = [&](AppColor::Role role) { + return config.appColors.value(role, defaultAppColor(role, dark)); + }; + + for (int pageIndex = 0; pageIndex < pageCount; ++pageIndex) { + const PreviewPage &page = pages[pageIndex]; + const qreal panelLeft = MARGIN + static_cast(pageIndex) * (panelWidth + MARGIN); + const qreal panelTop = MARGIN + 30; + const int panelHeight = panelHeightFor(page); + + painter.setPen(Qt::NoPen); + painter.setBrush(cell.darker(dark ? 115 : 103)); + painter.drawRoundedRect(QRectF(panelLeft, panelTop, panelWidth, panelHeight), 8, 8); + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold)); + painter.drawText(QPointF(panelLeft + PANEL_PADDING, panelTop + 17), page.title); + + qreal y = panelTop + PANEL_TITLE; + for (const PreviewSection §ion : page.sections) { + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold)); + painter.drawText(QPointF(panelLeft + PANEL_PADDING, y + 15), section.title); + y += SECTION_HEIGHT; + + const int rows = rowsIn(section); + for (int row = 0; row < rows; ++row) { + for (int column = 0; column < COLUMNS; ++column) { + const int index = row * COLUMNS + column; + if (index >= section.actions.size()) { + break; + } + const PreviewAction &action = section.actions.at(index); + const QRectF cellRect(panelLeft + PANEL_PADDING + column * CELL_WIDTH, y, CELL_WIDTH - 4, + CELL_HEIGHT - 4); + + painter.setPen(Qt::NoPen); + painter.setBrush(cell); + painter.drawRoundedRect(cellRect, 5, 5); + + const QByteArray svg = readIconSvg(action.glyph); + ASSERT_FALSE(svg.isEmpty()) << action.glyph; + const QPixmap pixmap = renderAtDpr(QSize(GLYPH, GLYPH), DPR, [&](const QSize &pixelSize) { + return renderInkGlyph(svg, toneFor(action.role), pixelSize); + }); + painter.drawPixmap(QPointF(cellRect.center().x() - GLYPH / 2.0, cellRect.top() + 7), pixmap); + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 7)); + const QFontMetrics metrics(painter.font()); + const QString label = metrics.elidedText(action.label, Qt::ElideRight, CELL_WIDTH - 12); + painter.drawText(QRectF(cellRect.left(), cellRect.bottom() - 20, cellRect.width(), 16), + Qt::AlignHCenter | Qt::AlignVCenter, label); + } + y += CELL_HEIGHT; + } + } + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath( + QStringLiteral("hover-panel-map-%1.png").arg(dark ? QStringLiteral("dark") : QStringLiteral("light"))); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } +} diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index 93203be6c..a56c723b4 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -1535,9 +1535,14 @@ TEST_F(ThemeIconRenderTest, EmphasisSelectsTheAuthoredVariant) AppColor::Role soft; }; - for (const Family &family : {Family{AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}, - Family{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, - Family{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}}) { + for (const Family &family : + {Family{AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}, + Family{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, + Family{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}, + Family{AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft}, + Family{AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft}, + Family{AppColor::Marked, AppColor::MarkedStrong, AppColor::MarkedSoft}, + Family{AppColor::Movement, AppColor::MovementStrong, AppColor::MovementSoft}}) { EXPECT_EQ(IconTint(family.base, IconEmphasis::Primary).resolve(QIcon::Normal), themeManager->appColor(family.strong)); EXPECT_EQ(IconTint(family.base).resolve(QIcon::Normal), themeManager->appColor(family.base)); @@ -1636,6 +1641,52 @@ TEST_F(ThemeIconRenderTest, EmphasisKeepsASemanticHue) EXPECT_GT(ink.red(), ink.green()) << theme.toStdString() << static_cast(role); EXPECT_GT(ink.red(), ink.blue()) << theme.toStdString() << static_cast(role); } + + for (const AppColor::Role role : + {AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft}) { + // Informational may come from the scheme default when the theme + // does not author it, so measure the value the client paints. + const QColor ink = roles.value(role, defaultAppColor(role, dark)); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.blue(), ink.red()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.blue(), ink.green()) << theme.toStdString() << static_cast(role); + } + + for (const AppColor::Role role : {AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft}) { + // Hidden is violet: blue leads, then red, then green. + const QColor ink = roles.value(role, defaultAppColor(role, dark)); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.blue(), ink.red()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.red(), ink.green()) << theme.toStdString() << static_cast(role); + } + + for (const AppColor::Role role : {AppColor::Marked, AppColor::MarkedStrong, AppColor::MarkedSoft}) { + // Marked is amber: red leads, then green, then blue. + const QColor ink = roles.value(role, defaultAppColor(role, dark)); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.red(), ink.green()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.green(), ink.blue()) << theme.toStdString() << static_cast(role); + } + + for (const AppColor::Role role : {AppColor::Movement, AppColor::MovementStrong, AppColor::MovementSoft}) { + // Movement is teal: green leads, then blue, then red. + const QColor ink = roles.value(role, defaultAppColor(role, dark)); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.green(), ink.blue()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.blue(), ink.red()) << theme.toStdString() << static_cast(role); + } } } EXPECT_GT(checked, 0) << "no shipped theme authored a semantic emphasis level"; @@ -1821,7 +1872,11 @@ TEST(IconInk, DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces) for (const Scheme &scheme : schemes) { for (const auto &[base, strong, soft] : {std::tuple{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, - std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}}) { + std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}, + std::tuple{AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft}, + std::tuple{AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft}, + std::tuple{AppColor::Marked, AppColor::MarkedStrong, AppColor::MarkedSoft}, + std::tuple{AppColor::Movement, AppColor::MovementStrong, AppColor::MovementSoft}}) { const QColor secondary = defaultAppColor(base, scheme.dark); const QColor primary = defaultAppColor(strong, scheme.dark); const QColor quiet = defaultAppColor(soft, scheme.dark); @@ -1908,6 +1963,18 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) AppColor::NegativeSoft, AppColor::Negative, AppColor::NegativeStrong}, + {"Informational", + {"arrow_right", "view", "arrow_to_top"}, + AppColor::InformationalSoft, + AppColor::Informational, + AppColor::InformationalStrong}, + {"Hidden", {"view", "arrow_right", "swap"}, AppColor::HiddenSoft, AppColor::Hidden, AppColor::HiddenStrong}, + {"Marked", {"pencil", "share", "pin"}, AppColor::MarkedSoft, AppColor::Marked, AppColor::MarkedStrong}, + {"Movement", + {"arrow_to_top", "arrow_to_bottom", "arrow_down"}, + AppColor::MovementSoft, + AppColor::Movement, + AppColor::MovementStrong}, }; const QStringList themes = { QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), @@ -1942,9 +2009,10 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) // The paint path is a lookup now, so the sheet shows the authored // rows verbatim rather than a color recomputed from the accent. const auto toneFor = [&](const Family &family, int level) { - return level == 0 ? palette.roles.value(family.quiet) - : level == 1 ? palette.roles.value(family.secondary) - : palette.roles.value(family.primary); + const AppColor::Role role = level == 0 ? family.quiet : level == 1 ? family.secondary : family.primary; + // A theme written before the role existed does not author it; + // the client paints the scheme default, so the sheet does too. + return palette.roles.value(role, defaultAppColor(role, dark)); }; const auto scheme = dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light; @@ -2100,6 +2168,18 @@ TEST_F(ThemeIconRenderTest, EmphasisTreatmentsPreviewWritesPngsWhenRequested) AppColor::NegativeSoft, AppColor::Negative, AppColor::NegativeStrong}, + {"Informational", + {"arrow_right", "view", "arrow_to_top"}, + AppColor::InformationalSoft, + AppColor::Informational, + AppColor::InformationalStrong}, + {"Hidden", {"view", "arrow_right", "swap"}, AppColor::HiddenSoft, AppColor::Hidden, AppColor::HiddenStrong}, + {"Marked", {"pencil", "share", "pin"}, AppColor::MarkedSoft, AppColor::Marked, AppColor::MarkedStrong}, + {"Movement", + {"arrow_to_top", "arrow_to_bottom", "arrow_down"}, + AppColor::MovementSoft, + AppColor::Movement, + AppColor::MovementStrong}, }; const QStringList themes = { QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), @@ -2184,7 +2264,9 @@ TEST_F(ThemeIconRenderTest, EmphasisTreatmentsPreviewWritesPngsWhenRequested) for (const Family &family : families) { for (int level = 0; level < 4; ++level) { const bool disabled = level == 3; - const QColor ink = disabled ? disabledTone : palette.roles.value(roleAt(family, level)); + const QColor ink = disabled ? disabledTone + : palette.roles.value(roleAt(family, level), + defaultAppColor(roleAt(family, level), dark)); const QString levelName = disabled ? QStringLiteral("Disabled") : QString::fromLatin1(LEVEL_NAMES[level]); ASSERT_TRUE(ink.isValid()) diff --git a/tests/theme_migration/theme_migration_test.cpp b/tests/theme_migration/theme_migration_test.cpp index 3bd8be3a2..329d13458 100644 --- a/tests/theme_migration/theme_migration_test.cpp +++ b/tests/theme_migration/theme_migration_test.cpp @@ -167,6 +167,30 @@ TEST(ThemeAppColorTest, SemanticDefaultsFollowTheScheme) EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, true), QColor(QStringLiteral("#ff8378"))); EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, false), QColor(QStringLiteral("#935f5c"))); EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, true), QColor(QStringLiteral("#c79e9b"))); + EXPECT_EQ(defaultAppColor(AppColor::Informational, false), QColor(QStringLiteral("#1c64c4"))); + EXPECT_EQ(defaultAppColor(AppColor::Informational, true), QColor(QStringLiteral("#7aa9f2"))); + EXPECT_EQ(defaultAppColor(AppColor::InformationalStrong, false), QColor(QStringLiteral("#0a4fb5"))); + EXPECT_EQ(defaultAppColor(AppColor::InformationalStrong, true), QColor(QStringLiteral("#69aeff"))); + EXPECT_EQ(defaultAppColor(AppColor::InformationalSoft, false), QColor(QStringLiteral("#526a8a"))); + EXPECT_EQ(defaultAppColor(AppColor::InformationalSoft, true), QColor(QStringLiteral("#9db2cd"))); + EXPECT_EQ(defaultAppColor(AppColor::Hidden, false), QColor(QStringLiteral("#6d28d9"))); + EXPECT_EQ(defaultAppColor(AppColor::Hidden, true), QColor(QStringLiteral("#c6a5fa"))); + EXPECT_EQ(defaultAppColor(AppColor::HiddenStrong, false), QColor(QStringLiteral("#4c0f9e"))); + EXPECT_EQ(defaultAppColor(AppColor::HiddenStrong, true), QColor(QStringLiteral("#c99bff"))); + EXPECT_EQ(defaultAppColor(AppColor::HiddenSoft, false), QColor(QStringLiteral("#6a5a8c"))); + EXPECT_EQ(defaultAppColor(AppColor::HiddenSoft, true), QColor(QStringLiteral("#b3a6c9"))); + EXPECT_EQ(defaultAppColor(AppColor::Marked, false), QColor(QStringLiteral("#9a5b05"))); + EXPECT_EQ(defaultAppColor(AppColor::Marked, true), QColor(QStringLiteral("#e2a63a"))); + EXPECT_EQ(defaultAppColor(AppColor::MarkedStrong, false), QColor(QStringLiteral("#8a4f02"))); + EXPECT_EQ(defaultAppColor(AppColor::MarkedStrong, true), QColor(QStringLiteral("#ffb020"))); + EXPECT_EQ(defaultAppColor(AppColor::MarkedSoft, false), QColor(QStringLiteral("#7b6a4a"))); + EXPECT_EQ(defaultAppColor(AppColor::MarkedSoft, true), QColor(QStringLiteral("#c2ab7d"))); + EXPECT_EQ(defaultAppColor(AppColor::Movement, false), QColor(QStringLiteral("#0e7a72"))); + EXPECT_EQ(defaultAppColor(AppColor::Movement, true), QColor(QStringLiteral("#4cc2b5"))); + EXPECT_EQ(defaultAppColor(AppColor::MovementStrong, false), QColor(QStringLiteral("#096b62"))); + EXPECT_EQ(defaultAppColor(AppColor::MovementStrong, true), QColor(QStringLiteral("#2dd4bf"))); + EXPECT_EQ(defaultAppColor(AppColor::MovementSoft, false), QColor(QStringLiteral("#4a6b67"))); + EXPECT_EQ(defaultAppColor(AppColor::MovementSoft, true), QColor(QStringLiteral("#a3c1bc"))); // Accent and neutral icon roles have no static default: an older theme // written before the roles existed falls back to deriving them from its // palette rather than pinning one scheme's ink.