Implement in-game navigation with keyboard

Implements a start to the keyboard navigation with the direction arrows
and the space key for card selection.
Some binds are still needed, but navigating the board is now possible.
The feature includes tests for the implemented feature.

Closes #5043

Co-authored-by: Manuel Ramos Monge <manuel.monge@tecnico.ulisboa.pt>
This commit is contained in:
Vasco Guerreiro Vintém Morais 2026-06-02 17:09:39 +01:00
parent 3fa377a11c
commit 5acce8998e
22 changed files with 1023 additions and 46 deletions

View file

@ -81,6 +81,7 @@ set(cockatrice_SOURCES
src/game/game_scene.cpp
src/game/game_state.cpp
src/game/game_view.cpp
src/game/keyboard_card_navigator.cpp
src/game/hand_counter.cpp
src/game/log/message_log_widget.cpp
src/game/phase.cpp

View file

@ -8,6 +8,7 @@
#include <QCursor>
#include <QGraphicsScene>
#include <QGraphicsSceneMouseEvent>
#include <QKeyEvent>
#include <QPainter>
#include <algorithm>
#include <libcockatrice/card/database/card_database.h>
@ -19,6 +20,7 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef
{
setCursor(Qt::OpenHandCursor);
setFlag(ItemIsSelectable);
setFlag(ItemIsFocusable);
setCacheMode(DeviceCoordinateCache);
connect(&SettingsCache::instance(), &SettingsCache::displayCardNamesChanged, this, [this] { update(); });
@ -347,3 +349,20 @@ QVariant AbstractCardItem::itemChange(QGraphicsItem::GraphicsItemChange change,
return ArrowTarget::itemChange(change, value);
}
}
void AbstractCardItem::keyPressEvent(QKeyEvent *event)
{
if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) {
if (event->modifiers() & Qt::AltModifier) {
emit cardShiftClicked(cardRef.name);
} else if (event->modifiers() & Qt::ControlModifier) {
setSelected(!isSelected());
} else if (!isSelected() && isHovered) {
scene()->clearSelection();
setSelected(true);
}
event->accept();
} else {
ArrowTarget::keyPressEvent(event);
}
}

View file

@ -127,6 +127,7 @@ protected:
void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override;
QVariant itemChange(QGraphicsItem::GraphicsItemChange change, const QVariant &value) override;
void cacheBgColor();
void keyPressEvent(QKeyEvent *event) override;
};
#endif

View file

@ -228,52 +228,9 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
if (targetItem && targetItem != startItem) {
CardItem *startCard = qgraphicsitem_cast<CardItem *>(startItem);
// For now, we can safely assume that the start item is always a card.
// The target item can be a player as well.
if (!startCard) {
delArrow();
return;
if (startCard) {
ArrowItem::sendCreateArrowCommand(player, startCard, targetItem, color, deleteInPhase);
}
CardZoneLogic *startZone = startCard->getZone();
Command_CreateArrow cmd;
cmd.mutable_arrow_color()->CopyFrom(convertQColorToColor(color));
cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId());
cmd.set_start_zone(startZone->getName().toStdString());
cmd.set_start_card_id(startCard->getId());
if (auto *targetCard = qgraphicsitem_cast<CardItem *>(targetItem)) {
CardZoneLogic *targetZone = targetCard->getZone();
cmd.set_target_player_id(targetZone->getPlayer()->getPlayerInfo()->getId());
cmd.set_target_zone(targetZone->getName().toStdString());
cmd.set_target_card_id(targetCard->getId());
} else if (auto *targetPlayer = qgraphicsitem_cast<PlayerTarget *>(targetItem)) {
cmd.set_target_player_id(targetPlayer->getOwner()->getPlayerInfo()->getId());
} else {
delArrow();
return;
}
// if the card is in hand then we will move the card to stack or table as part of drawing the arrow
if (startZone->getName() == ZoneNames::HAND) {
startCard->playCard(false);
CardInfoPtr ci = startCard->getCard().getCardPtr();
bool playToStack = SettingsCache::instance().getPlayToStack();
if (ci && ((!playToStack && ci->getUiAttributes().tableRow == 3) ||
(playToStack && ci->getUiAttributes().tableRow != 0 &&
startCard->getZone()->getName() != ZoneNames::STACK))) {
cmd.set_start_zone(ZoneNames::STACK);
} else {
cmd.set_start_zone(playToStack ? ZoneNames::STACK : ZoneNames::TABLE);
}
}
if (deleteInPhase != 0) {
cmd.set_delete_in_phase(deleteInPhase);
}
player->getPlayerActions()->sendGameCommand(cmd);
}
delArrow();
@ -282,6 +239,55 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
}
}
void ArrowItem::sendCreateArrowCommand(PlayerLogic *player,
CardItem *startCard,
ArrowTarget *targetItem,
const QColor &color,
int deleteInPhase)
{
if (!startCard || !targetItem || !player) {
return;
}
CardZoneLogic *startZone = startCard->getZone();
Command_CreateArrow cmd;
cmd.mutable_arrow_color()->CopyFrom(convertQColorToColor(color));
cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId());
cmd.set_start_zone(startZone->getName().toStdString());
cmd.set_start_card_id(startCard->getId());
if (auto *targetCard = qgraphicsitem_cast<CardItem *>(targetItem)) {
CardZoneLogic *targetZone = targetCard->getZone();
cmd.set_target_player_id(targetZone->getPlayer()->getPlayerInfo()->getId());
cmd.set_target_zone(targetZone->getName().toStdString());
cmd.set_target_card_id(targetCard->getId());
} else if (auto *targetPlayer = qgraphicsitem_cast<PlayerTarget *>(targetItem)) {
cmd.set_target_player_id(targetPlayer->getOwner()->getPlayerInfo()->getId());
} else {
return;
}
// if the card is in hand then we will move the card to stack or table as part of drawing the arrow
if (startZone->getName() == ZoneNames::HAND) {
startCard->playCard(false);
CardInfoPtr ci = startCard->getCard().getCardPtr();
bool playToStack = SettingsCache::instance().getPlayToStack();
if (ci && ((!playToStack && ci->getUiAttributes().tableRow == 3) ||
(playToStack && ci->getUiAttributes().tableRow != 0 &&
startCard->getZone()->getName() != ZoneNames::STACK))) {
cmd.set_start_zone(ZoneNames::STACK);
} else {
cmd.set_start_zone(playToStack ? ZoneNames::STACK : ZoneNames::TABLE);
}
}
if (deleteInPhase != 0) {
cmd.set_delete_in_phase(deleteInPhase);
}
player->getPlayerActions()->sendGameCommand(cmd);
}
// ArrowAttachItem
ArrowAttachItem::ArrowAttachItem(ArrowTarget *_startItem)
: ArrowItem(_startItem->getOwner(), -1, _startItem, nullptr, Qt::green)

View file

@ -77,6 +77,11 @@ public:
}
void delArrow();
static void sendCreateArrowCommand(PlayerLogic *player,
CardItem *startCard,
ArrowTarget *targetItem,
const QColor &color,
int deleteInPhase = 0);
};
class ArrowDragItem : public ArrowItem

View file

@ -5,6 +5,7 @@
#include "../../game_graphics/zones/view_zone.h"
#include "../../interface/widgets/tabs/tab_game.h"
#include "../game_scene.h"
#include "../keyboard_card_navigator.h"
#include "../phase.h"
#include "../player/player_actions.h"
#include "../player/player_logic.h"
@ -14,7 +15,10 @@
#include <../../client/settings/card_counter_settings.h>
#include <QApplication>
#include <QCursor>
#include <QGraphicsSceneMouseEvent>
#include <QGraphicsView>
#include <QKeyEvent>
#include <QMenu>
#include <QPainter>
#include <libcockatrice/card/card_info.h>
@ -500,6 +504,71 @@ void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
event->accept();
}
void CardItem::keyPressEvent(QKeyEvent *event)
{
auto *gameScene = static_cast<GameScene *>(scene());
KeyboardCardNavigator *navigator = gameScene ? gameScene->getCardNavigator() : nullptr;
if (event->key() == Qt::Key_Escape && navigator && navigator->isArrowModeActiveVar()) {
navigator->cancelArrowMode();
event->accept();
qWarning() << "Arrow mode cancelled from CardItem with id:" << id;
return;
}
if (event->key() == Qt::Key_A && (event->modifiers() & Qt::ShiftModifier) && getIsHovered()) {
qWarning() << "Starting arrow mode from CardItem with id:" << id;
if (navigator) {
qWarning() << "Navigator found, starting arrow mode.";
scene()->clearSelection();
setSelected(true);
navigator->startArrowMode(this);
event->accept();
return;
}
}
if ((event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) && navigator &&
navigator->isArrowModeActiveVar() && getIsHovered()) {
qWarning() << "Finalizing arrow mode from CardItem with id:" << id;
navigator->createArrow(this);
navigator->cancelArrowMode();
event->accept();
return;
}
if ((event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) && isSelected() &&
SettingsCache::instance().getDoubleClickToPlay()) {
handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier));
event->accept();
} else if (event->key() == Qt::Key_Space && getIsHovered()) {
if (QWidget *popup = QApplication::activePopupWidget()) {
popup->close();
event->accept();
return;
}
if (owner != nullptr) {
scene()->clearSelection();
setSelected(true);
owner->getGame()->setActiveCard(this);
if (QMenu *cardMenu = owner->getPlayerMenu()->updateCardMenu(this)) {
QPointF scenePos = sceneBoundingRect().center();
if (!scene()->views().isEmpty()) {
QGraphicsView *view = scene()->views().first();
QPoint screenPos = view->mapToGlobal(view->mapFromScene(scenePos));
cardMenu->popup(screenPos);
}
}
}
event->accept();
} else {
AbstractCardItem::keyPressEvent(event);
}
}
bool CardItem::animationEvent()
{
int rotation = ROTATION_DEGREES_PER_FRAME;

View file

@ -169,6 +169,7 @@ protected:
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;
void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override;
void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
QVariant itemChange(GraphicsItemChange change, const QVariant &value) override;
};

View file

@ -5,6 +5,7 @@
#include "../game_graphics/zones/view_zone.h"
#include "../game_graphics/zones/view_zone_widget.h"
#include "board/card_item.h"
#include "keyboard_card_navigator.h"
#include "phases_toolbar.h"
#include "player/player_graphics_item.h"
#include "player/player_logic.h"
@ -28,9 +29,11 @@
* Finally, calls rearrange() to layout players initially.
*/
GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent)
: QGraphicsScene(parent), phasesToolbar(_phasesToolbar), viewSize(QSize()), playerRotation(0)
: QGraphicsScene(parent), phasesToolbar(_phasesToolbar), viewSize(QSize()), playerRotation(0),
cardNavigator(nullptr)
{
animationTimer = new QBasicTimer;
cardNavigator = new KeyboardCardNavigator(nullptr);
addItem(phasesToolbar);
connect(&SettingsCache::instance(), &SettingsCache::minPlayersForMultiColumnLayoutChanged, this,
&GameScene::rearrange);
@ -41,6 +44,7 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent)
GameScene::~GameScene()
{
delete animationTimer;
delete cardNavigator;
// DO NOT call clearViews() here
// clearViews calls close() on the zoneViews, which sends signals; sending signals in destructors leads to segfaults
@ -460,6 +464,46 @@ void GameScene::onCardZoneChanged(CardItem *card, bool sameZone)
}
}
void GameScene::handleLeftArrow()
{
if (cardNavigator) {
QKeyEvent event(QEvent::KeyPress, Qt::Key_Left, Qt::NoModifier);
cardNavigator->switchCardInZone(&event);
}
}
void GameScene::handleRightArrow()
{
if (cardNavigator) {
QKeyEvent event(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier);
cardNavigator->switchCardInZone(&event);
}
}
void GameScene::handleUpArrow()
{
if (cardNavigator) {
QKeyEvent event(QEvent::KeyPress, Qt::Key_Up, Qt::NoModifier);
cardNavigator->switchZone(&event);
}
}
void GameScene::handleDownArrow()
{
if (cardNavigator) {
QKeyEvent event(QEvent::KeyPress, Qt::Key_Down, Qt::NoModifier);
cardNavigator->switchZone(&event);
}
}
void GameScene::setActivePlayer(PlayerLogic *player)
{
if (cardNavigator) {
cardNavigator->setPlayer(player);
cardNavigator->setCurrentZone(player->getHandZone());
}
}
// ---------- Hover Handling ----------
void GameScene::updateHover(const QPointF &scenePos)

View file

@ -23,6 +23,7 @@ class CardItem;
class ServerInfo_Card;
class PhasesToolbar;
class QBasicTimer;
class KeyboardCardNavigator;
/**
* @class GameScene
@ -52,6 +53,7 @@ private:
QBasicTimer *animationTimer; ///< Timer for card animations
QSet<CardItem *> cardsToAnimate; ///< Cards currently animating
int playerRotation; ///< Rotation offset for player layout
KeyboardCardNavigator *cardNavigator; ///< Handles keyboard-based card navigation
/**
* @brief Updates which card is currently hovered based on scene coordinates.
@ -171,6 +173,12 @@ public:
/** @brief Updates hovered card highlighting. */
void updateHoveredCard(CardItem *newCard);
/** @brief Gets the keyboard card navigator. */
KeyboardCardNavigator *getCardNavigator() const
{
return cardNavigator;
}
/** @brief Registers a card for animation updates. */
void registerAnimationItem(AbstractCardItem *card);
@ -206,6 +214,17 @@ public slots:
void deleteArrow(int arrowId);
void clearArrowsForPlayer(int playerId);
/** @brief Handles left arrow key for card navigation. */
void handleLeftArrow();
/** @brief Handles right arrow key for card navigation. */
void handleRightArrow();
/** @brief Handles up arrow key for zone navigation. */
void handleUpArrow();
/** @brief Handles down arrow key for zone navigation. */
void handleDownArrow();
/** @brief Sets the active player for keyboard navigation. */
void setActivePlayer(PlayerLogic *player);
/// Queues up arrow deletion but doesn't directly modify the scene
void requestArrowDeletion(int arrowId);
void requestClearArrowsForPlayer(int playerId);

View file

@ -0,0 +1,256 @@
#include "keyboard_card_navigator.h"
#include "../../client/settings/cache_settings.h"
#include "board/arrow_item.h"
#include "board/card_item.h"
#include "player/player_logic.h"
#include <QApplication>
#include <QKeyEvent>
KeyboardCardNavigator::KeyboardCardNavigator(PlayerLogic *player)
: currentZone(nullptr), hoveredCardIndex(-1), isArrowModeActive(false), arrowOriginCard(nullptr),
previewArrow(nullptr), playerLogic(player)
{
}
int KeyboardCardNavigator::getHoveredIndex()
{
return hoveredCardIndex;
}
CardZoneLogic *KeyboardCardNavigator::getCurrentZone()
{
return currentZone;
}
void KeyboardCardNavigator::setCurrentZone(CardZoneLogic *zone)
{
currentZone = zone;
}
void KeyboardCardNavigator::switchCardInZone(QKeyEvent *event)
{
if (!playerLogic) {
return;
}
if (!currentZone) {
return;
}
if (QApplication::activePopupWidget()) {
return;
}
const CardList &zoneCards = currentZone->getCards();
if (zoneCards.isEmpty()) {
// if the current zone is empty, try to force a zone change.
QKeyEvent event(QEvent::KeyPress, Qt::Key_Up, Qt::NoModifier);
KeyboardCardNavigator::switchZone(&event);
return;
}
event->accept();
// Check if this is an arrow key we care about
int keyCode = event->key();
if (keyCode != Qt::Key_Right && keyCode != Qt::Key_Left) {
return;
}
// Calculate new index
int newIndex = hoveredCardIndex;
bool isInitial = (hoveredCardIndex < 0);
if (isInitial) {
// If the first key pressed is the right, spawn the cursor at the first card
// Otherwise, spawn at the last card.
newIndex = keyCode == Qt::Key_Right ? 0 : zoneCards.size() - 1;
} else {
if (hoveredCardIndex >= zoneCards.size()) {
hoveredCardIndex = 0;
newIndex = 0;
}
if (keyCode == Qt::Key_Right) {
newIndex = (hoveredCardIndex + 1) % zoneCards.size();
} else {
newIndex = (hoveredCardIndex - 1 + zoneCards.size()) % zoneCards.size();
}
}
KeyboardCardNavigator::changeHoverCard(hoveredCardIndex, false);
hoveredCardIndex = newIndex;
KeyboardCardNavigator::changeHoverCard(newIndex, true);
}
void KeyboardCardNavigator::changeHoverCard(int cardIndex, bool hover)
{
const CardList &zoneCards = currentZone->getCards();
if (cardIndex >= 0 && cardIndex < zoneCards.size()) {
CardItem *card = zoneCards[cardIndex];
if (card) {
card->setHovered(hover);
card->setFocus();
// Force update of new card's area
if (card->scene()) {
card->scene()->update(card->sceneBoundingRect());
}
if (isArrowModeActive && hover) {
createTempArrow(card);
}
}
}
}
void KeyboardCardNavigator::setPlayer(PlayerLogic *player)
{
playerLogic = player;
}
void KeyboardCardNavigator::setHoveredCardIndex(int index)
{
hoveredCardIndex = index;
}
void KeyboardCardNavigator::unhoverCard()
{
if (!playerLogic || !currentZone) {
return;
}
changeHoverCard(hoveredCardIndex, false);
}
void KeyboardCardNavigator::createTempArrow(CardItem *targetCard)
{
if (!isArrowModeActive || !arrowOriginCard || !targetCard || !playerLogic) {
return;
}
if (previewArrow) {
delete previewArrow;
previewArrow = nullptr;
}
previewArrow = new ArrowItem(playerLogic, -1, arrowOriginCard, targetCard, Qt::red);
if (arrowOriginCard->scene()) {
arrowOriginCard->scene()->addItem(previewArrow);
}
}
void KeyboardCardNavigator::createArrow(CardItem *targetCard)
{
if (!isArrowModeActive || !arrowOriginCard || !targetCard || !playerLogic) {
return;
}
if (previewArrow) {
delete previewArrow;
previewArrow = nullptr;
}
isArrowModeActive = false;
if (arrowOriginCard == targetCard) {
arrowOriginCard = nullptr;
return;
}
ArrowItem::sendCreateArrowCommand(playerLogic, arrowOriginCard, targetCard, Qt::red);
arrowOriginCard = nullptr;
}
void KeyboardCardNavigator::startArrowMode(CardItem *originCard)
{
if (!originCard || !originCard->scene() || !playerLogic) {
return;
}
isArrowModeActive = true;
arrowOriginCard = originCard;
createTempArrow(originCard);
}
void KeyboardCardNavigator::cancelArrowMode()
{
if (previewArrow) {
delete previewArrow;
previewArrow = nullptr;
}
isArrowModeActive = false;
arrowOriginCard = nullptr;
}
CardZoneLogic *
KeyboardCardNavigator::findZoneWithCards(QList<CardZoneLogic *> &zonesList, int currentZoneIndex, bool upperZone)
{
CardZoneLogic *newZone;
int newZoneIndex = currentZoneIndex;
do {
// Calculate new zone index
if (upperZone) {
newZoneIndex = (newZoneIndex + 1) % zonesList.size();
} else {
newZoneIndex = (newZoneIndex - 1 + zonesList.size()) % zonesList.size();
}
newZone = zonesList[newZoneIndex];
// Prevent switching zone if the others are empty
} while (newZoneIndex != currentZoneIndex && newZone->getCards().size() == 0);
return newZone;
}
void KeyboardCardNavigator::switchZone(QKeyEvent *event)
{
if (!playerLogic) {
return;
}
if (QApplication::activePopupWidget()) {
return;
}
int keyCode = event->key();
if (keyCode != Qt::Key_Up && keyCode != Qt::Key_Down) {
return;
}
event->accept();
// Build list with only the zones of interest
QList<CardZoneLogic *> zonesList;
TableZoneLogic *tableZone = playerLogic->getTableZone();
StackZoneLogic *stackZone = playerLogic->getStackZone();
HandZoneLogic *handZone = playerLogic->getHandZone();
if (tableZone) {
zonesList.append(tableZone);
}
if (stackZone) {
zonesList.append(stackZone);
}
if (handZone) {
zonesList.append(handZone);
}
if (zonesList.isEmpty()) {
return;
}
int currentZoneIndex = zonesList.indexOf(currentZone);
if (currentZoneIndex < 0) {
currentZoneIndex = 0;
}
CardZoneLogic *newZone =
KeyboardCardNavigator::findZoneWithCards(zonesList, currentZoneIndex, keyCode == Qt::Key_Up);
if (currentZone != newZone) {
changeHoverCard(hoveredCardIndex, false);
setCurrentZone(newZone);
// Reset card index since we're in a new zone
hoveredCardIndex = 0;
// The new zone has to have cards, hover and select the first one
changeHoverCard(hoveredCardIndex, true);
}
}

View file

@ -0,0 +1,61 @@
/**
* @file keyboard_card_navigator.h
* @ingroup GameInput
* @brief Handles keyboard navigation for selecting cards using arrow keys.
*
* Allows players to navigate through hand cards using directional arrow keys.
* Spatially-aware: finds adjacent cards based on screen position.
*/
#ifndef KEYBOARD_CARD_NAVIGATOR_H
#define KEYBOARD_CARD_NAVIGATOR_H
#include "zones/card_zone_logic.h"
class PlayerLogic;
class CardItem;
class QKeyEvent;
class ArrowItem;
class KeyboardCardNavigator
{
private:
CardZoneLogic *currentZone;
int hoveredCardIndex = -1;
bool isArrowModeActive;
CardItem *arrowOriginCard;
ArrowItem *previewArrow;
PlayerLogic *playerLogic;
/**
* @brief Gets hand cards sorted by visual position.
* @return List of cards sorted by visual order (left-to-right for horizontal, top-to-bottom for vertical).
*/
CardList getVisuallyOrderedHandCards() const;
public:
KeyboardCardNavigator(PlayerLogic *player = nullptr);
int getHoveredIndex();
CardZoneLogic *getCurrentZone();
void setCurrentZone(CardZoneLogic *zone);
void switchCardInZone(QKeyEvent *event);
void switchZone(QKeyEvent *event);
void setPlayer(PlayerLogic *player);
/**
* @brief Validates and resets the hovered card if needed.
* Call this when hand composition changes.
*/
void setHoveredCardIndex(int index);
void unhoverCard();
void changeHoverCard(int cardIndex, bool hover);
void createArrow(CardItem *targetCard);
void createTempArrow(CardItem *targetCard);
void startArrowMode(CardItem *originCard);
void cancelArrowMode();
CardZoneLogic *findZoneWithCards(QList<CardZoneLogic *> &zonesList, int currentZoneIndex, bool upperZone);
bool isArrowModeActiveVar() const
{
return isArrowModeActive;
}
};
#endif

View file

@ -11,6 +11,7 @@
#include "../board/card_list.h"
#include "../board/counter_general.h"
#include "../game_scene.h"
#include "../keyboard_card_navigator.h"
#include "player_actions.h"
#include "player_target.h"
@ -323,6 +324,9 @@ bool PlayerLogic::clearCardsToDelete()
void PlayerLogic::setActive(bool _active)
{
active = _active;
if (_active == true) {
hoverFirstCardInHand();
}
emit activeChanged(active);
}
@ -348,3 +352,20 @@ void PlayerLogic::setGameStarted()
}
setConceded(false);
}
void PlayerLogic::hoverFirstCardInHand()
{
HandZoneLogic *handZone = getHandZone();
if (!handZone) {
return;
}
const CardList &handCards = handZone->getCards();
if (!handCards.isEmpty()) {
CardItem *firstCard = handCards.at(0);
if (firstCard) {
firstCard->setHovered(true);
getGameScene()->getCardNavigator()->unhoverCard();
getGameScene()->getCardNavigator()->setHoveredCardIndex(0);
}
}
}

View file

@ -229,6 +229,8 @@ public:
void setZoneId(int _zoneId);
void hoverFirstCardInHand();
private:
AbstractGame *game;
PlayerInfo *playerInfo;

View file

@ -1,5 +1,6 @@
#include "key_signals.h"
#include <QApplication>
#include <QKeyEvent>
bool KeySignals::eventFilter(QObject * /*object*/, QEvent *event)
@ -26,15 +27,35 @@ bool KeySignals::eventFilter(QObject * /*object*/, QEvent *event)
case Qt::Key_Right:
if (kevent->modifiers() & Qt::ShiftModifier) {
emit onShiftRight();
} else {
emit onRightArrow();
return true;
}
break;
case Qt::Key_Left:
if (kevent->modifiers() & Qt::ShiftModifier) {
emit onShiftLeft();
} else {
emit onLeftArrow();
return true;
}
break;
case Qt::Key_Up:
// This check exists so up and down arrows can still navigate all menu boxes while having
// normal browsing functionality in game
if (qApp->activePopupWidget() != nullptr || qApp->activeModalWidget() != nullptr) {
return false;
}
emit onUpArrow();
return true;
case Qt::Key_Down:
if (qApp->activePopupWidget() != nullptr || qApp->activeModalWidget() != nullptr) {
return false;
}
emit onDownArrow();
return true;
case Qt::Key_Delete:
case Qt::Key_Backspace:
emit onDelete();

View file

@ -28,6 +28,10 @@ signals:
void onCtrlAltRBracket();
void onShiftS();
void onCtrlC();
void onLeftArrow();
void onRightArrow();
void onUpArrow();
void onDownArrow();
protected:
bool eventFilter(QObject *, QEvent *event) override;

View file

@ -14,6 +14,7 @@
#include "../game/player/player_logic.h"
#include "../game/replay.h"
#include "../interface/card_picture_loader/card_picture_loader.h"
#include "../interface/key_signals.h"
#include "../interface/widgets/cards/card_info_frame_widget.h"
#include "../interface/widgets/dialogs/dlg_create_game.h"
#include "../interface/widgets/server/user/user_list_manager.h"
@ -863,6 +864,7 @@ PlayerLogic *TabGame::setActivePlayer(int id)
}
playerListWidget->setActivePlayer(id);
scene->setActivePlayer(player);
QMapIterator<int, PlayerLogic *> i(game->getPlayerManager()->getPlayers());
while (i.hasNext()) {
i.next();
@ -1153,6 +1155,14 @@ void TabGame::createPlayAreaWidget(bool bReplay)
connect(scene, &GameScene::arrowDeletionRequested, game->getGameEventHandler(),
&GameEventHandler::handleArrowDeletion);
connect(game->getGameEventHandler(), &GameEventHandler::arrowDeleted, scene, &GameScene::deleteArrow);
keySignals = new KeySignals();
qApp->installEventFilter(keySignals);
connect(keySignals, &KeySignals::onLeftArrow, scene, &GameScene::handleLeftArrow);
connect(keySignals, &KeySignals::onRightArrow, scene, &GameScene::handleRightArrow);
connect(keySignals, &KeySignals::onUpArrow, scene, &GameScene::handleUpArrow);
connect(keySignals, &KeySignals::onDownArrow, scene, &GameScene::handleDownArrow);
gameView = new GameView(scene);
auto gamePlayAreaVBox = new QVBoxLayout;

View file

@ -51,6 +51,7 @@ class GameReplay;
class LineEditCompleter;
class QDockWidget;
class QStackedWidget;
class KeySignals;
class TabGame : public Tab
{
@ -73,6 +74,7 @@ private:
PhasesToolbar *phasesToolbar;
GameScene *scene;
GameView *gameView;
KeySignals *keySignals;
QMap<int, TabbedDeckViewContainer *> deckViewContainers;
QVBoxLayout *deckViewContainerLayout;
QWidget *gamePlayAreaWidget, *deckViewContainerWidget;

View file

@ -75,6 +75,7 @@ target_link_libraries(
)
add_subdirectory(card_zone_algorithms)
add_subdirectory(keyboard_navigator_tests)
add_subdirectory(carddatabase)
add_subdirectory(loading_from_clipboard)
add_subdirectory(movecard_tests)

View file

@ -0,0 +1,32 @@
add_executable(keyboard_navigator_test keyboard_card_navigator_test.cpp keyboard_navigator_test_stubs.cpp)
target_compile_options(keyboard_navigator_test PRIVATE --coverage)
target_link_options(keyboard_navigator_test PRIVATE --coverage)
target_include_directories(
keyboard_navigator_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src ${CMAKE_SOURCE_DIR}/cockatrice/src/game
${CMAKE_SOURCE_DIR} ${CMAKE_SOURCE_DIR}/libcockatrice_interfaces
)
target_link_libraries(
keyboard_navigator_test
PRIVATE Threads::Threads
PRIVATE ${GTEST_BOTH_LIBRARIES}
PRIVATE ${TEST_QT_MODULES}
PRIVATE libcockatrice_settings
PRIVATE libcockatrice_interfaces
PRIVATE libcockatrice_protocol
PRIVATE libcockatrice_card
PRIVATE libcockatrice_deck_list
PRIVATE libcockatrice_models
PRIVATE libcockatrice_rng
PRIVATE libcockatrice_network
PRIVATE libcockatrice_utility
PRIVATE Qt6::Widgets
)
add_test(NAME keyboard_navigator_test COMMAND keyboard_navigator_test)
if(NOT GTEST_FOUND)
add_dependencies(keyboard_navigator_test gtest)
endif()

View file

@ -0,0 +1,229 @@
#include "game/keyboard_card_navigator.h"
#include <QApplication>
#include <QKeyEvent>
#include <QList>
#include <QMap>
#include <QMetaObject>
#include <gtest/gtest.h>
// Some tests require us to get the zones out of a player, so instead of changing code we didn't make, we
// decided to use this dirty trick.
#define private public
#include "game/player/player_logic.h"
#undef private
#include "game/board/abstract_card_item.h"
#include "game/board/arrow_item.h"
#include "game/board/card_item.h"
#include "game/board/card_list.h"
#include "game/keyboard_card_navigator.cpp"
#include "game/zones/card_zone_logic.h"
#include "game/zones/hand_zone_logic.h"
#include "game/zones/stack_zone_logic.h"
#include "game/zones/table_zone_logic.h"
#include "keyboard_navigator_test_fakes.h"
class KeyboardCardNavigatorTest : public ::testing::Test
{
protected:
KeyboardCardNavigator *navigator;
FakeHandZoneLogic *handZone;
FakeTableZoneLogic *tableZone;
FakeStackZoneLogic *stackZone;
PlayerLogic *player;
void SetUp() override
{
handZone = new FakeHandZoneLogic("hand");
tableZone = new FakeTableZoneLogic("table");
stackZone = new FakeStackZoneLogic("stack");
// Player is mocked like this, so we fill the zones for the tests.
player = (PlayerLogic *)malloc(sizeof(PlayerLogic));
new (&player->zones) QMap<QString, CardZoneLogic *>();
player->zones.insert("table", tableZone);
player->zones.insert("stack", stackZone);
player->zones.insert("hand", handZone);
navigator = new KeyboardCardNavigator(player);
}
void TearDown() override
{
delete navigator;
free(player);
delete handZone;
delete tableZone;
delete stackZone;
}
};
/* This test verifies the behaviour of spawning the cursor:
When pressing the left arrow, it goes to the first card.
When pressing the right arrow, it goes to the last card of the zone. */
TEST_F(KeyboardCardNavigatorTest, LeftRightArrowTest)
{
// Set an arbitrary amount of cards for the zone
handZone->setDummyCardCount(5);
// Set the default index
navigator->setHoveredCardIndex(-1);
navigator->setCurrentZone(handZone);
// Simulate a key press and a card switch
QKeyEvent eRight(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier);
navigator->switchCardInZone(&eRight);
// Verify the first card is selected
EXPECT_EQ(navigator->getHoveredIndex(), 0);
// Reset the index
navigator->setHoveredCardIndex(-1);
// Simulate a key press and a card switch
QKeyEvent eLeft(QEvent::KeyPress, Qt::Key_Left, Qt::NoModifier);
navigator->switchCardInZone(&eLeft);
// Check if the last card was selected
EXPECT_EQ(navigator->getHoveredIndex(), 4);
}
/* This test verifies the moving behaviour of the cursor. */
TEST_F(KeyboardCardNavigatorTest, NormalSwitchCards)
{
// Set an arbitrary amount of cards for the zone
handZone->setDummyCardCount(5);
navigator->setCurrentZone(handZone);
// Select the second card
navigator->setHoveredCardIndex(1);
// If right is pressed, go to index + 1
QKeyEvent eRight(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier);
navigator->switchCardInZone(&eRight);
EXPECT_EQ(navigator->getHoveredIndex(), 2);
// If left is pressed, go to index - 1
QKeyEvent eLeft(QEvent::KeyPress, Qt::Key_Left, Qt::NoModifier);
navigator->switchCardInZone(&eLeft);
EXPECT_EQ(navigator->getHoveredIndex(), 1);
}
/* This test verifies the edge case moving behaviour of the cursor.
If we are on the first card, and left is pressed, we should go to the
last card, and vice-versa */
TEST_F(KeyboardCardNavigatorTest, ZoneLoopsTest)
{
// Set an arbitrary amount of cards for the zone
tableZone->setDummyCardCount(2);
// Select the first card
navigator->setCurrentZone(tableZone);
navigator->setHoveredCardIndex(0);
// If we press left, it wraps around to the end, and the zone doesn't change
QKeyEvent eLeft(QEvent::KeyPress, Qt::Key_Left, Qt::NoModifier);
navigator->switchCardInZone(&eLeft);
EXPECT_EQ(navigator->getHoveredIndex(), 1);
EXPECT_EQ(navigator->getCurrentZone(), tableZone);
// If we press right, it wraps around to the start, and the zone doesn't change
QKeyEvent eRight(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier);
navigator->switchCardInZone(&eRight);
EXPECT_EQ(navigator->getHoveredIndex(), 0);
EXPECT_EQ(navigator->getCurrentZone(), tableZone);
}
/* This test verifies the switching of zones if the zone we are currently on is empty. */
TEST_F(KeyboardCardNavigatorTest, EmptyZoneLoopTest)
{
QList<CardZoneLogic *> zonesList;
zonesList.append(tableZone);
zonesList.append(stackZone);
zonesList.append(handZone);
tableZone->setDummyCardCount(2);
stackZone->setDummyCardCount(0); // empty
handZone->setDummyCardCount(2);
// Simulate key up when switching zones
CardZoneLogic *newZone = navigator->findZoneWithCards(zonesList, 0, true);
// The result should be zone 2
EXPECT_EQ(newZone, handZone);
// Simulate key down when switching zones
newZone = navigator->findZoneWithCards(zonesList, 2, false);
// The result should be zone 2
EXPECT_EQ(newZone, tableZone);
}
/* This test verifies the switching of zones with the up and down keys. */
TEST_F(KeyboardCardNavigatorTest, SwitchZoneTest)
{
tableZone->setDummyCardCount(2);
stackZone->setDummyCardCount(2);
handZone->setDummyCardCount(2);
navigator->setCurrentZone(tableZone);
navigator->setHoveredCardIndex(0);
// Simulate key up when switching zones
QKeyEvent eUp(QEvent::KeyPress, Qt::Key_Up, Qt::NoModifier);
navigator->switchZone(&eUp);
// The result should be zone 1 and the card selected is the first one
EXPECT_EQ(navigator->getCurrentZone(), stackZone);
EXPECT_EQ(navigator->getHoveredIndex(), 0);
// Simulate key down when switching zones
QKeyEvent eDown(QEvent::KeyPress, Qt::Key_Down, Qt::NoModifier);
navigator->switchZone(&eDown);
// The result should be zone 0 and the card selected is the first one
EXPECT_EQ(navigator->getCurrentZone(), tableZone);
EXPECT_EQ(navigator->getHoveredIndex(), 0);
}
/* This test verifies the case where every zone is empty. */
TEST_F(KeyboardCardNavigatorTest, EmptyZoneSwitchZones)
{
QList<CardZoneLogic *> zonesList;
zonesList.append(tableZone);
zonesList.append(stackZone);
zonesList.append(handZone);
tableZone->setDummyCardCount(0);
stackZone->setDummyCardCount(0);
handZone->setDummyCardCount(0);
// The expected zone is the starting one
CardZoneLogic *newZone = navigator->findZoneWithCards(zonesList, 0, true);
EXPECT_EQ(newZone, tableZone);
}
/* This test verifies the behaviour when someone moves a card with the mouse and the
keyboard is used after a zone becomes empty. */
TEST_F(KeyboardCardNavigatorTest, ZoneEmptySwitchTest)
{
tableZone->setDummyCardCount(1);
stackZone->setDummyCardCount(0);
handZone->setDummyCardCount(1);
// Set a card as hovered in the table zone
navigator->setCurrentZone(tableZone);
navigator->setHoveredCardIndex(0);
// Simulate the user deleting/moving all cards out of the table zone
tableZone->setDummyCardCount(0);
// Now the user presses an arrow key in the empty zone
QKeyEvent eRight(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier);
navigator->switchCardInZone(&eRight);
// The expected zone is the next one with cards
EXPECT_EQ(navigator->getCurrentZone(), handZone);
EXPECT_EQ(navigator->getHoveredIndex(), 0);
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View file

@ -0,0 +1,67 @@
#ifndef KEYBOARD_NAVIGATOR_TEST_FAKES_H
#define KEYBOARD_NAVIGATOR_TEST_FAKES_H
#include "game/board/card_item.h"
#include "game/zones/card_zone_logic.h"
#include "game/zones/hand_zone_logic.h"
#include "game/zones/stack_zone_logic.h"
#include "game/zones/table_zone_logic.h"
// Define safe macro replacements for the visual tests
#define setFocus() zValue()
#define getVisuallyOrderedHandCards() getCards()
namespace QApplicationMock
{
extern bool hasPopup;
}
class FakeCardZoneLogic : public CardZoneLogic
{
public:
QString name;
FakeCardZoneLogic(const QString &n) : CardZoneLogic(nullptr, n, false, false, true, nullptr), name(n)
{
}
void addCardImpl(CardItem *, int, int) override
{
}
void setDummyCardCount(int count)
{
cards.clear();
for (int i = 0; i < count; i++) {
cards.insert(i, (CardItem *)nullptr);
}
}
const QString getName() const
{
return name;
}
};
#define DECLARE_FAKE_ZONE(FakeName, TargetName) \
class FakeName : public FakeCardZoneLogic \
{ \
public: \
FakeName(const QString &n) : FakeCardZoneLogic(n) \
{ \
} \
const QMetaObject *metaObject() const override \
{ \
return &TargetName::staticMetaObject; \
} \
void *qt_metacast(const char *clname) override \
{ \
if (!clname) \
return nullptr; \
if (!strcmp(clname, #TargetName)) \
return static_cast<void *>(this); \
return FakeCardZoneLogic::qt_metacast(clname); \
} \
};
DECLARE_FAKE_ZONE(FakeTableZoneLogic, TableZoneLogic)
DECLARE_FAKE_ZONE(FakeStackZoneLogic, StackZoneLogic)
DECLARE_FAKE_ZONE(FakeHandZoneLogic, HandZoneLogic)
#endif // KEYBOARD_NAVIGATOR_TEST_FAKES_H

View file

@ -0,0 +1,106 @@
#include "game/board/abstract_card_item.h"
#include "game/board/arrow_item.h"
#include "game/board/card_item.h"
#include "game/board/card_list.h"
#include "game/player/player_logic.h"
#include "game/zones/card_zone_logic.h"
#include "game/zones/hand_zone_logic.h"
#include "game/zones/stack_zone_logic.h"
#include "game/zones/table_zone_logic.h"
#include <QApplication>
#include <QColor>
#include <QMetaObject>
#include <QString>
// Stubs for AbstractCardItem
void AbstractCardItem::setHovered(bool)
{
}
// Stubs for ArrowItem
ArrowItem::ArrowItem(PlayerLogic *, int, ArrowTarget *, ArrowTarget *, QColor const &)
: QObject(nullptr), QGraphicsItem(nullptr)
{
}
void ArrowItem::sendCreateArrowCommand(PlayerLogic *, CardItem *, ArrowTarget *, QColor const &, int)
{
}
void ArrowItem::paint(QPainter *, QStyleOptionGraphicsItem const *, QWidget *)
{
}
void ArrowItem::mousePressEvent(QGraphicsSceneMouseEvent *)
{
}
const QMetaObject *ArrowItem::metaObject() const
{
return nullptr;
}
void *ArrowItem::qt_metacast(const char *)
{
return nullptr;
}
int ArrowItem::qt_metacall(QMetaObject::Call, int, void **)
{
return 0;
}
// Stubs for QMetaObject
const QMetaObject TableZoneLogic::staticMetaObject = {
{&QObject::staticMetaObject, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}};
const QMetaObject StackZoneLogic::staticMetaObject = {
{&QObject::staticMetaObject, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}};
const QMetaObject HandZoneLogic::staticMetaObject = {
{&QObject::staticMetaObject, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}};
const QMetaObject PlayerLogic::staticMetaObject = {
{&QObject::staticMetaObject, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}};
const QMetaObject CardZoneLogic::staticMetaObject = {
{&QObject::staticMetaObject, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}};
// Stubs for CardList
CardList::CardList(bool) : QList<CardItem *>()
{
}
// Stubs for CardZoneLogic
CardZoneLogic::CardZoneLogic(PlayerLogic *, const QString &, bool, bool, bool _contentsKnown, QObject *)
: QObject(nullptr), cards(_contentsKnown)
{
}
CardItem *CardZoneLogic::getCard(int)
{
return nullptr;
}
CardItem *CardZoneLogic::takeCard(int, int, bool)
{
return nullptr;
}
void CardZoneLogic::addCardImpl(CardItem *, int, int)
{
}
QString CardZoneLogic::getTranslatedName(bool, GrammaticalCase) const
{
return QString();
}
const QMetaObject *CardZoneLogic::metaObject() const
{
return nullptr;
}
void *CardZoneLogic::qt_metacast(const char *)
{
return nullptr;
}
int CardZoneLogic::qt_metacall(QMetaObject::Call, int, void **)
{
return 0;
}
// QApplication Mock implementation
namespace QApplicationMock
{
bool hasPopup = false;
}
QWidget *QApplication::activePopupWidget()
{
return QApplicationMock::hasPopup ? (QWidget *)1 : nullptr;
}