Show the library edge on the card in limited deck views (#6741) (#6741)

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>
This commit is contained in:
scotland0208 2026-11-07 10:24:40 -06:00 • committed by GitHub
parent 80986266b9
commit d9cd308a2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 243 additions and 2 deletions

View file

@ -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 <QApplication>
#include <QBrush>
#include <QColor>
#include <QDebug>
#include <QFont>
#include <QFontMetrics>
#include <QMessageLogger>
#include <QObject>
#include <QPainter>
#include <QPalette>
#include <QSharedPointer>
#include <QtGlobal>
#include <QtMath>
@ -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<ZoneViewZoneLogic *>(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<CardItem *>(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<ZoneViewZoneLogic *>(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<CardItem *>(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<ZoneViewZoneLogic *>(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<ZoneViewZoneLogic *>(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();

View file

@ -9,9 +9,11 @@
#include "select_zone.h"
#include <QGraphicsItem>
#include <QGraphicsLayoutItem>
#include <QList>
#include <QLoggingCategory>
#include <QPointF>
#include <QRectF>
#include <QSizeF>
#include <QString>
@ -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<CardDragItem *> &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();

View file

@ -294,6 +294,7 @@ void ZoneViewWidget::retranslateUi()
shuffleCheckBox.setText(tr("shuffle when closing"));
pileViewCheckBox.setText(tr("pile view"));
zone->retranslateEdgeMarker();
}
void ZoneViewWidget::stopWindowDrag()

View file

@ -7,6 +7,7 @@
#include <QtGlobal>
#include <libcockatrice/interfaces/interface_interface_settings_provider.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/zone_names.h>
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.

View file

@ -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;

View file

@ -5,6 +5,7 @@
#include <libcockatrice/game/zones/hand_zone_logic.h>
#include <libcockatrice/game/zones/view_zone_logic.h>
#include <libcockatrice/utility/card_ref.h>
#include <libcockatrice/utility/zone_names.h>
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());
}