diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index be2e471d4..1716fc24d 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -1,21 +1,27 @@ #include "view_zone.h" #include "../../client/settings/cache_settings.h" +#include "../../interface/theme_manager.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" #include "../card_dimensions.h" +#include "../z_values.h" #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/filters/filter_string.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/serverinfo_zone.pb.h" #include "libcockatrice/utility/card_ref.h" +#include #include #include #include +#include +#include #include #include #include +#include #include #include #include @@ -34,12 +40,85 @@ class QGraphicsItem; +namespace +{ +constexpr qreal MARKER_HORIZONTAL_PADDING = 4.0; +constexpr qreal MARKER_VERTICAL_PADDING = 2.0; +constexpr qreal MARKER_CORNER_RADIUS = 4.0; +constexpr qreal MARKER_INSET = 2.0; +constexpr int MARKER_FONT_SIZE = 9; +// Vertical pitch of a card column: each card shows this much before the next +// card overlaps it. Marker placement uses it to stay on the visible strip. +constexpr qreal CARD_STACK_OFFSET = CardDimensions::HEIGHT_F / 3.0; + +QFont markerFont() +{ + QFont font = themeManager != nullptr ? themeManager->appFont(AppFont::CardText) : QFont(); + font.setPixelSize(MARKER_FONT_SIZE); + font.setWeight(QFont::Bold); + return font; +} +} // namespace + +LibraryEdgeMarker::LibraryEdgeMarker(QGraphicsItem *parent) : QGraphicsItem(parent) +{ + setAcceptedMouseButtons(Qt::NoButton); + setAcceptHoverEvents(false); +} + +void LibraryEdgeMarker::setLabel(const QString &newLabel, const QString &toolTip) +{ + if (label == newLabel && this->toolTip() == toolTip) { + return; + } + + prepareGeometryChange(); + label = newLabel; + + const QFontMetrics metrics(markerFont()); + markerRect = QRectF(0, 0, metrics.horizontalAdvance(label) + 2 * MARKER_HORIZONTAL_PADDING, + metrics.height() + 2 * MARKER_VERTICAL_PADDING); + + setToolTip(toolTip); + update(); +} + +QRectF LibraryEdgeMarker::boundingRect() const +{ + return markerRect; +} + +void LibraryEdgeMarker::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) +{ + if (label.isEmpty()) { + return; + } + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setFont(markerFont()); + + // ToolTipBase/ToolTipText are the palette's designed background/text pair + // for small overlays, so the chip keeps its contrast in every theme. + const QPalette palette = qApp->palette(); + painter->setPen(QPen(palette.color(QPalette::Mid), 1)); + painter->setBrush(palette.color(QPalette::ToolTipBase)); + painter->drawRoundedRect(markerRect.adjusted(0.5, 0.5, -0.5, -0.5), MARKER_CORNER_RADIUS, MARKER_CORNER_RADIUS); + + painter->setPen(palette.color(QPalette::ToolTipText)); + painter->drawText(markerRect, Qt::AlignCenter, label); + painter->restore(); +} + /** * @param parent the parent QGraphicsWidget containing the reveal zone */ ZoneViewZone::ZoneViewZone(ZoneViewZoneLogic *_logic, QGraphicsItem *parent) : SelectZone(_logic, parent), bRect(QRectF()), minRows(0), groupBy(CardList::NoSort), sortBy(CardList::NoSort) { + libraryEdgeMarker = new LibraryEdgeMarker(this); + libraryEdgeMarker->setVisible(false); + auto *viewLogic = qobject_cast(getLogic()); if (!(viewLogic->getRevealZone() && !viewLogic->getWriteableRevealZone())) { viewLogic->getOriginalZone()->getViews().append(viewLogic); @@ -183,6 +262,8 @@ void ZoneViewZone::reorganizeCards() gridSize = positionCardsForDisplay(cardsToDisplay); } + updateLibraryEdgeMarker(cardsToDisplay); + // determine bounding rect qreal aleft = 0; qreal atop = 0; @@ -232,7 +313,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca lastColumnProp = columnProp; qreal x = col * CardDimensions::WIDTH_F; - qreal y = row * CardDimensions::HEIGHT_F / 3; + qreal y = row * CARD_STACK_OFFSET; cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); cItem->setRealZValue(i); longestRow = qMax(row, longestRow); @@ -261,7 +342,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca for (int i = 0; i < cardCount; i++) { auto *cItem = qobject_cast(cards.at(i)->parent()); qreal x = (i / rows) * CardDimensions::WIDTH_F; - qreal y = (i % rows) * CardDimensions::HEIGHT_F / 3; + qreal y = (i % rows) * CARD_STACK_OFFSET; cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); cItem->setRealZValue(i); } @@ -270,6 +351,75 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca } } +void ZoneViewZone::updateLibraryEdgeMarker(const CardList &displayedCards) +{ + auto *viewLogic = qobject_cast(getLogic()); + if (viewLogic == nullptr || !viewLogic->isLibraryEdgeView() || displayedCards.isEmpty()) { + libraryEdgeMarkerAnchorPos = QPointF(); + libraryEdgeMarker->hide(); + return; + } + + const bool topEdge = viewLogic->showsTopOfLibrary(); + CardState *edgeState = topEdge ? displayedCards.first() : displayedCards.last(); + auto *edgeCard = edgeState == nullptr ? nullptr : qobject_cast(edgeState->parent()); + if (edgeCard == nullptr) { + libraryEdgeMarkerAnchorPos = QPointF(); + libraryEdgeMarker->hide(); + return; + } + + libraryEdgeMarkerAnchorPos = edgeCard->pos(); + applyLibraryEdgeMarkerText(); + positionLibraryEdgeMarker(); + + // Above the hovered-card layer: the chip sits inside its card, so hovering + // that card must not let the card repaint over the indicator. + libraryEdgeMarker->setZValue(ZValues::HOVERED_CARD + 0.5); + libraryEdgeMarker->show(); +} + +void ZoneViewZone::positionLibraryEdgeMarker() +{ + // The chip has to stay on the part of its card that is not covered by the + // next card in the column. The top card only shows its top strip, and its + // name is drawn top-left, so the chip goes to the right of that strip. The + // bottom card is fully visible, so the chip goes to its bottom-left, away + // from the bottom-right power/toughness. + const auto *viewLogic = qobject_cast(getLogic()); + const QRectF markerRect = libraryEdgeMarker->boundingRect(); + const bool topEdge = viewLogic != nullptr && viewLogic->showsTopOfLibrary(); + const QPointF offset = topEdge + ? QPointF(CardDimensions::WIDTH_F - markerRect.width() - MARKER_INSET, + CARD_STACK_OFFSET - markerRect.height() - MARKER_INSET) + : QPointF(MARKER_INSET, CardDimensions::HEIGHT_F - markerRect.height() - MARKER_INSET); + libraryEdgeMarker->setPos(libraryEdgeMarkerAnchorPos + offset); +} + +void ZoneViewZone::applyLibraryEdgeMarkerText() +{ + const auto *viewLogic = qobject_cast(getLogic()); + if (viewLogic == nullptr) { + return; + } + + const bool topEdge = viewLogic->showsTopOfLibrary(); + //: Short badge on the card at the top or bottom of a viewed library. The full phrase is in the tooltip. + libraryEdgeMarker->setLabel(topEdge ? tr("Top") : tr("Bottom"), + topEdge ? tr("This card is on top of the library") + : tr("This card is at the bottom of the library")); +} + +void ZoneViewZone::retranslateEdgeMarker() +{ + if (libraryEdgeMarker->isVisible()) { + // The translated label can be a different width, so reposition the chip + // to keep it inside its card. + applyLibraryEdgeMarkerText(); + positionLibraryEdgeMarker(); + } +} + void ZoneViewZone::setFilterString(const QString &_filterString) { const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay(); diff --git a/cockatrice/src/game_graphics/zones/view_zone.h b/cockatrice/src/game_graphics/zones/view_zone.h index f91b24834..9593ceeb9 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.h +++ b/cockatrice/src/game_graphics/zones/view_zone.h @@ -9,9 +9,11 @@ #include "select_zone.h" +#include #include #include #include +#include #include #include #include @@ -33,6 +35,30 @@ inline Q_LOGGING_CATEGORY(ViewZoneLog, "view_zone"); class Response; class ServerInfo_Card; class QGraphicsSceneWheelEvent; +class QPainter; +class QStyleOptionGraphicsItem; +class QWidget; + +/** + * A small chip drawn on the card at the edge of a viewed library. + * + * It is a sibling of the card items rather than part of the zone widget, so it + * stays attached to its card when the view reflows into several columns or is + * resized. ZoneViewZone owns and positions it. + */ +class LibraryEdgeMarker : public QGraphicsItem +{ +public: + explicit LibraryEdgeMarker(QGraphicsItem *parent = nullptr); + + void setLabel(const QString &label, const QString &toolTip); + [[nodiscard]] QRectF boundingRect() const override; + void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; + +private: + QRectF markerRect; + QString label; +}; /** * A CardZone that is a view into another CardZone. @@ -56,6 +82,8 @@ private: FilterString filterString = FilterString(""); CardList::SortOption groupBy, sortBy; bool pileView; + LibraryEdgeMarker *libraryEdgeMarker; + QPointF libraryEdgeMarkerAnchorPos; struct GridSize { @@ -64,6 +92,9 @@ private: }; GridSize positionCardsForDisplay(CardList &cards, CardList::SortOption pileOption = CardList::NoSort); + void updateLibraryEdgeMarker(const CardList &displayedCards); + void applyLibraryEdgeMarkerText(); + void positionLibraryEdgeMarker(); void handleDropEvent(const QList &dragItems, CardZoneLogic *startZone, const QPoint &dropPoint) override; @@ -79,6 +110,10 @@ public: { return optimumRect; } + + /** @brief Re-applies the translated library edge marker text after a language change. */ + void retranslateEdgeMarker(); + public slots: void addToViews(); void removeFromViews(); diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index 3c6cf9d02..2efa2fd13 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -294,6 +294,7 @@ void ZoneViewWidget::retranslateUi() shuffleCheckBox.setText(tr("shuffle when closing")); pileViewCheckBox.setText(tr("pile view")); + zone->retranslateEdgeMarker(); } void ZoneViewWidget::stopWindowDrag() diff --git a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp index db1fa32c3..f5fcd8786 100644 --- a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp @@ -7,6 +7,7 @@ #include #include #include +#include class PlayerLogic; class QObject; @@ -36,6 +37,11 @@ bool ZoneViewZoneLogic::getCloseEmptyCardView() const return interfaceSettings ? interfaceSettings->getCloseEmptyCardView() : true; } +bool ZoneViewZoneLogic::isLibraryEdgeView() const +{ + return origZone != nullptr && numberCards > 0 && origZone->getName() == ZoneNames::DECK; +} + /** * Checks if inserting a card at the given position requires an actual new card to be created and added to the view. * Also does any cardId updates that would be required if a card is inserted in that position. diff --git a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h index a29e543a3..ee8156058 100644 --- a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h @@ -82,6 +82,27 @@ public: return isReversed; } + /** + * @brief Whether this view is a limited look at one end of a library. + * + * Only scry/surveil-style views of the top or bottom cards of a deck + * qualify. Full-zone views, revealed-card views and views of other zones + * do not, because their first and last cards have no library edge meaning. + */ + [[nodiscard]] bool isLibraryEdgeView() const; + + /** @brief Whether the first card in the view is the top card of the library. */ + [[nodiscard]] bool showsTopOfLibrary() const + { + return isLibraryEdgeView() && !isReversed; + } + + /** @brief Whether the last card in the view is the bottom card of the library. */ + [[nodiscard]] bool showsBottomOfLibrary() const + { + return isLibraryEdgeView() && isReversed; + } + CardZoneLogic *getOriginalZone() const { return origZone; diff --git a/tests/game/card_zone_logic_test.cpp b/tests/game/card_zone_logic_test.cpp index 7f0c39b47..e308d95a6 100644 --- a/tests/game/card_zone_logic_test.cpp +++ b/tests/game/card_zone_logic_test.cpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace { @@ -105,3 +106,30 @@ TEST(CardZoneLogicTest, ReorganizeSignalFiresOnAdd) EXPECT_EQ(reorganizeSpy.count(), 1); } + +TEST(CardZoneLogicTest, LibraryEdgeViewIsLimitedToOneEndOfTheLibrary) +{ + HandZoneLogic deck(nullptr, QString::fromLatin1(ZoneNames::DECK), false, true, true); + HandZoneLogic grave(nullptr, QString::fromLatin1(ZoneNames::GRAVE), false, true, true); + + ZoneViewZoneLogic fullDeckView(nullptr, &deck, -1, false, false, false); + EXPECT_FALSE(fullDeckView.isLibraryEdgeView()); + EXPECT_FALSE(fullDeckView.showsTopOfLibrary()); + EXPECT_FALSE(fullDeckView.showsBottomOfLibrary()); + + ZoneViewZoneLogic emptyDeckView(nullptr, &deck, 0, false, false, false); + EXPECT_FALSE(emptyDeckView.isLibraryEdgeView()); + + ZoneViewZoneLogic topView(nullptr, &deck, 3, false, false, false); + EXPECT_TRUE(topView.isLibraryEdgeView()); + EXPECT_TRUE(topView.showsTopOfLibrary()); + EXPECT_FALSE(topView.showsBottomOfLibrary()); + + ZoneViewZoneLogic bottomView(nullptr, &deck, 3, false, false, true); + EXPECT_TRUE(bottomView.isLibraryEdgeView()); + EXPECT_FALSE(bottomView.showsTopOfLibrary()); + EXPECT_TRUE(bottomView.showsBottomOfLibrary()); + + ZoneViewZoneLogic graveView(nullptr, &grave, 3, false, false, false); + EXPECT_FALSE(graveView.isLibraryEdgeView()); +}