[Interface] Address hover panel review findings

This commit is contained in:
Lukas Brübach 2026-11-02 10:17:40 +01:00 • committed by Lukas Brübach
parent 6b636baa60
commit 9fa6c06601
10 changed files with 137 additions and 29 deletions

View file

@ -13,6 +13,7 @@
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/utility/card_ref.h"
#include <QAbstractAnimation>
#include <QApplication>
#include <QBrush>
#include <QChar>
@ -47,13 +48,16 @@ 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);
@ -79,7 +83,9 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef
// so siblings and the clip container cannot cut through the shrinking card.
connect(hoverZoom, &QPropertyAnimation::finished, this, [this] {
if (!isHovered) {
setZValue(realZValue);
// 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()) {
@ -164,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);
}
}
@ -262,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()) {
@ -334,6 +340,47 @@ void AbstractCardItem::setHovered(bool _hovered)
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();
}
void AbstractCardItem::setColor(const QString &_color)
{
state->setColor(_color);

View file

@ -37,6 +37,7 @@ protected:
private:
bool isHovered;
bool isHoverHighlighted;
qreal realZValue;
bool pendingSelectionCollapse;
int selectionCollapseGeneration = 0; ///< Guards the deferred collapse timer against stale firings
@ -134,6 +135,16 @@ 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;

View file

@ -268,15 +268,17 @@ void GameScene::onCardRightClicked(AbstractCardItem *abstractCard, QPoint screen
}
// The zoom and panel triggers are independently bindable: a right-click fires
// whichever phases listen to it. Once right-click drives either phase the context
// menu stays suppressed — popping it would steal the flow (and the panel already
// carries every action when it is the panel trigger).
// 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);
return;
if (panelFired) {
return;
}
}
// Hide the hover popup when the context menu opens
@ -843,6 +845,17 @@ void GameScene::updateHoveredCard(CardItem *newCard)
hoveredCard = nullptr;
}
// 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();

View file

@ -78,8 +78,11 @@ private:
ArrowRegistry arrowRegistry; ///< ID registry for arrow graphics items
QList<ZoneViewWidget *> zoneViews; ///< Active zone view widgets
QSize viewSize; ///< Current view size
QPointer<CardItem> hoveredCard; ///< Currently hovered card
QPointer<CardItem> activeCard; ///< Card the currently open context menu targets
QPointer<CardItem> 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<CardItem> hoverHighlightCard;
QPointer<CardItem> activeCard; ///< Card the currently open context menu targets
/// Card armed for double-confirm panel summon (first trigger zooms, repeat shows).
QPointer<CardItem> confirmArmedCard;
/// Ancestor chain of the hovered card with pre-hover Z-values. Lifted while hovering

View file

@ -284,9 +284,8 @@ void CardHoverWidget::createNameBarOverlay()
if (!nameEdit->isVisible()) {
return;
}
if (currentCard && currentCard->getOwner()) {
auto *actions = currentCard->getOwner()->getPlayerActions();
if (actions) {
if (canModifyCurrentCard()) {
if (auto *actions = currentCard->getOwner()->getPlayerActions()) {
QList<CardState *> cardList{currentCard->getState()};
actions->actSetAnnotation(cardList, nameEdit->text());
}
@ -771,6 +770,23 @@ void CardHoverWidget::onCardDestroyed()
// ---------- 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<ZoneViewZoneLogic *>(currentCard->getZone())) {
if (view->getRevealZone() && !view->getWriteableRevealZone()) {
return false;
}
}
return true;
}
void CardHoverWidget::rebuildActionsForZone()
{
clearLayout(mainPageLayout);
@ -785,12 +801,7 @@ void CardHoverWidget::rebuildActionsForZone()
auto *card = currentCard.data();
QList<CardState *> cardList{card->getState()};
bool canModify = currentPlayer->getLogic()->getPlayerInfo()->getLocalOrJudge();
if (auto *view = qobject_cast<ZoneViewZoneLogic *>(card->getZone())) {
if (view->getRevealZone() && !view->getWriteableRevealZone()) {
canModify = false;
}
}
const bool canModify = canModifyCurrentCard();
QString zoneName = card->getZone() ? card->getZone()->getName() : QString();
@ -1175,6 +1186,11 @@ void CardHoverWidget::setPTExpanded(bool expanded)
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;
@ -1558,8 +1574,9 @@ bool CardHoverWidget::isClosing() const
bool CardHoverWidget::eventFilter(QObject *obj, QEvent *event)
{
// Handle double-click on name label to start editing annotation
if (obj == nameLabel && event->type() == QEvent::MouseButtonDblClick) {
// 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);

View file

@ -174,6 +174,8 @@ private:
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<CardState *> &cardList, const QString &zoneName, bool canModify);
void buildMoveTab(const QList<CardState *> &cardList, const QString &zoneName, bool canModify);
void buildCounterTab(const QList<CardState *> &cardList, const QString &zoneName, bool canModify);

View file

@ -33,7 +33,7 @@ enum visualDeckStoragePromptForConversionIndex
static int clampHoverTrigger(int trigger)
{
return qBound(static_cast<int>(CardsDisplaySettings::HoverPanelTriggerHover), trigger,
static_cast<int>(CardsDisplaySettings::HoverPanelTriggerShortcut));
static_cast<int>(CardsDisplaySettings::HoverPanelTriggerOff));
}
UserInterfaceSettingsPage::UserInterfaceSettingsPage()
@ -167,6 +167,7 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
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(),
@ -176,6 +177,7 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
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(),
@ -426,9 +428,13 @@ void UserInterfaceSettingsPage::updateHoverPanelUiState()
// 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 sharedTrigger = hoverZoomTriggerSelector.currentIndex() == hoverPanelTriggerSelector.currentIndex();
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);
@ -480,6 +486,7 @@ void UserInterfaceSettingsPage::retranslateUi()
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:"));

View file

@ -70,12 +70,16 @@ bool CardsDisplaySettings::getScaleCards() const
int CardsDisplaySettings::getHoverPanelTrigger() const
{
return getValue("hoverPanelTrigger", QString(), QString(), 0).toInt();
// Off by default: the panel is new, so it is opt-in.
return getValue("hoverPanelTrigger", QString(), QString(), static_cast<int>(HoverPanelTriggerOff)).toInt();
}
int CardsDisplaySettings::getHoverZoomTrigger() const
{
return getValue("hoverZoomTrigger", QString(), QString(), 0).toInt();
// 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<int>(HoverPanelTriggerOff)).toInt();
}
bool CardsDisplaySettings::getHoverPanelDoubleConfirm() const

View file

@ -22,7 +22,9 @@ public:
HoverPanelTriggerHover = 0,
HoverPanelTriggerRightClick = 1,
HoverPanelTriggerWheel = 2,
HoverPanelTriggerShortcut = 3
HoverPanelTriggerShortcut = 3,
/// Never fires. The hover zoom and panel are opt-in, so this is their default.
HoverPanelTriggerOff = 4
};
[[nodiscard]] bool getDisplayCardNames() const override;

View file

@ -686,7 +686,8 @@ TEST_F(SettingsDefaultsTest, CardsDisplay_ArrowDrawAnimation_Default)
TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelTrigger_Default)
{
CardsDisplaySettings s(settingsPath, nullptr);
ASSERT_EQ(s.getHoverPanelTrigger(), 0);
// Off by default: the panel is opt-in on upgrade.
ASSERT_EQ(s.getHoverPanelTrigger(), CardsDisplaySettings::HoverPanelTriggerOff);
}
TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelTrigger_SetAndGet)
@ -701,7 +702,8 @@ TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelTrigger_SetAndGet)
TEST_F(SettingsDefaultsTest, CardsDisplay_HoverZoomTrigger_Default)
{
CardsDisplaySettings s(settingsPath, nullptr);
ASSERT_EQ(s.getHoverZoomTrigger(), 0);
// Off by default: the smooth hover zoom is opt-in on upgrade.
ASSERT_EQ(s.getHoverZoomTrigger(), CardsDisplaySettings::HoverPanelTriggerOff);
}
TEST_F(SettingsDefaultsTest, CardsDisplay_HoverZoomTrigger_SetAndGet)