From d9cd308a2e3071b90abcffc44d25339233f5f964 Mon Sep 17 00:00:00 2001 From: scotland0208 Date: Sat, 7 Nov 2026 10:24:40 -0600 Subject: [PATCH] Show the library edge on the card in limited deck views (#6741) (#6741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Limited deck views (view top/bottom N cards, scry, surveil) made it ambiguous which card was the top or bottom of the library. Label the card itself with a small in-view marker instead of placing text above or below the whole zone, which became ambiguous once the grid used several columns or the window was resized. The marker stays on the part of its card that the column leaves visible: for a top view, the right end of the exposed strip of the first card; for a bottom view, the bottom-left corner of the last, fully visible card. The view logic now answers whether it is a limited library-end view and which edge it shows. The graphics zone draws and positions a marker on the first or last displayed card, and re-translates its tooltip with the rest of the zone view. Co-authored-by: Lukas BrĂ¼bach <44814898+BruebachL@users.noreply.github.com> --- .../src/game_graphics/zones/view_zone.cpp | 154 +++++++++++++++++- .../src/game_graphics/zones/view_zone.h | 35 ++++ .../game_graphics/zones/view_zone_widget.cpp | 1 + .../game/zones/view_zone_logic.cpp | 6 + .../game/zones/view_zone_logic.h | 21 +++ tests/game/card_zone_logic_test.cpp | 28 ++++ 6 files changed, 243 insertions(+), 2 deletions(-) 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()); +}