diff --git a/cockatrice/src/client/settings/shortcuts_settings.h b/cockatrice/src/client/settings/shortcuts_settings.h index 202b39247..94b981d83 100644 --- a/cockatrice/src/client/settings/shortcuts_settings.h +++ b/cockatrice/src/client/settings/shortcuts_settings.h @@ -313,6 +313,12 @@ private: {"DeckViewContainer/forceStartGameButton", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Force Start"), parseSequenceString(""), ShortcutGroup::Game_Lobby)}, + {"GameSelector/aQuickHostGame", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Host Game"), + parseSequenceString("Ctrl+N"), + ShortcutGroup::Game_Lobby)}, + {"GameSelector/aReviewGame", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Game Settings"), + parseSequenceString("Ctrl+Shift+N"), + ShortcutGroup::Game_Lobby)}, {"Player/aCCMagenta", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Add Card Counter (F)"), parseSequenceString(""), ShortcutGroup::Card_Counters)}, diff --git a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp index 048899266..acef35ebc 100644 --- a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp +++ b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp @@ -36,6 +36,13 @@ GameCreationOptions GameCreationOptions::fromSettings(const IGameSettingsProvide return options; } +GameCreationOptions GameCreationOptions::defaultsFor(bool registeredUser) +{ + GameCreationOptions options; + options.onlyRegistered = registeredUser; + return options; +} + GameCreationOptions GameCreationOptions::fromGameInfo(const ServerInfo_Game &gameInfo, const QMap &gameTypes) { diff --git a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h index cbc941815..d9a2bc0cc 100644 --- a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h +++ b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h @@ -49,6 +49,14 @@ struct GameCreationOptions [[nodiscard]] static GameCreationOptions fromSettings(const IGameSettingsProvider &settings, const QMap &gameTypes, bool registeredUser); + /** + * @brief The options the host form shows when nothing is remembered. + * + * A registered host defaults to a registered-only game, matching the form's + * ``Clear`` action. + */ + [[nodiscard]] static GameCreationOptions defaultsFor(bool registeredUser); + /** * @brief Reads the read-only fields shown in the game information view. */ diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 9dc7bf1e8..c3bc7499f 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -1,9 +1,11 @@ #include "game_selector.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../theme_icon.h" #include "../interface/widgets/dialogs/dlg_create_game.h" #include "../interface/widgets/dialogs/dlg_filter_games.h" +#include "../interface/widgets/game_hosting/game_creation_options.h" #include "../interface/widgets/tabs/tab_room.h" #include "../interface/widgets/tabs/tab_supervisor.h" #include "../interface/widgets/utility/get_text_with_max.h" @@ -15,13 +17,18 @@ #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "user/user_list_manager.h" +#include +#include #include +#include #include #include +#include #include #include #include #include +#include #include #include #include @@ -31,15 +38,19 @@ #include #include #include +#include +#include #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -51,6 +62,33 @@ class QPoint; class QWidget; template class QList; +namespace +{ + +/** + * @brief Returns whether the application paints with the native macOS style. + * + * QMacStyle ignores autoRaise and flat, and draws a QToolButton menu as a second square + * bezel, so the split host control cannot be composed there. A global style sheet wraps the + * real application style in a QStyleSheetStyle that takes it as a child, so that layer is + * unwrapped before the style is identified. + */ +bool usesMacStyle() +{ + QStyle *style = QApplication::style(); + if (style == nullptr) { + return false; + } + if (style->inherits("QStyleSheetStyle")) { + if (QStyle *baseStyle = style->findChild(QString(), Qt::FindDirectChildrenOnly)) { + style = baseStyle; + } + } + return style->inherits("QMacStyle"); +} + +} // namespace + GameSelector::GameSelector(AbstractClient *_client, TabSupervisor *_tabSupervisor, TabRoom *_room, @@ -140,10 +178,59 @@ GameSelector::GameSelector(AbstractClient *_client, connect(gameListProxyModel, &GamesProxyModel::filtersChanged, this, &GameSelector::checkClearFilterButtonState); if (room) { - createButton = new QPushButton; - connect(createButton, &QPushButton::clicked, this, &GameSelector::actCreate); - } else { - createButton = nullptr; + hostGameAction = new QAction(this); + hostGameAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(hostGameAction, &QAction::triggered, this, &GameSelector::actQuickHost); + + // A plain QPushButton, so the quick host sits in the same control family as the + // join buttons. A QPushButton does not take its text, tooltip or enabled state from + // an action, so those are mirrored by hand below and in updateHostControlsEnabled(). + hostGameButton = new QPushButton(this); + hostGameButton->setObjectName("hostGameButton"); + connect(hostGameButton, &QPushButton::clicked, hostGameAction, &QAction::trigger); + + hostGameMenu = new QMenu(this); + hostGameMenu->setObjectName("hostGameMenu"); + hostGameMenu->addAction(hostGameAction); + gameSettingsAction = hostGameMenu->addAction(QString()); + gameSettingsAction->setObjectName("gameSettingsAction"); + gameSettingsAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(gameSettingsAction, &QAction::triggered, this, &GameSelector::actCreate); + + // The secondary control: a flat arrow next to the primary button on styles that + // support a split control. The native macOS style does not, so there it is a plain + // sibling button instead (see applyHostGameStyle()). + hostGameMenuButton = new QToolButton(this); + hostGameMenuButton->setObjectName("hostGameMenuButton"); + hostGameMenuButton->setArrowType(Qt::DownArrow); + hostGameMenuButton->setAutoRaise(true); + hostGameMenuButton->setPopupMode(QToolButton::InstantPopup); + hostGameMenuButton->setToolButtonStyle(Qt::ToolButtonIconOnly); + + hostGameSettingsButton = new QPushButton(this); + hostGameSettingsButton->setObjectName("hostGameSettingsButton"); + connect(hostGameSettingsButton, &QPushButton::clicked, gameSettingsAction, &QAction::trigger); + + hostGameButtonWidget = new QWidget(this); + auto *hostGameButtonLayout = new QHBoxLayout(hostGameButtonWidget); + hostGameButtonLayout->setContentsMargins(0, 0, 0, 0); + hostGameButtonLayout->setSpacing(0); + hostGameButtonLayout->addWidget(hostGameButton); + hostGameButtonLayout->addWidget(hostGameMenuButton); + + applyHostGameStyle(); + + // The shortcuts belong to the whole room tab, not just the game list, so they also + // fire while the chat or the user list has focus. A QToolButton menu is mouse-only, + // so the settings action needs its own shortcut for keyboard parity. + if (auto *roomTab = qobject_cast(parentWidget())) { + roomTab->addAction(hostGameAction); + roomTab->addAction(gameSettingsAction); + } else { + addAction(hostGameAction); + addAction(gameSettingsAction); + } + refreshShortcuts(); } joinButton = new QPushButton; joinAsJudgeButton = new QPushButton; @@ -157,7 +244,8 @@ GameSelector::GameSelector(AbstractClient *_client, } buttonLayout->addStretch(); if (room) { - buttonLayout->addWidget(createButton); + buttonLayout->addWidget(hostGameButtonWidget); + buttonLayout->addWidget(hostGameSettingsButton); } buttonLayout->addWidget(joinButton); if (tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsJudge) { @@ -173,10 +261,16 @@ GameSelector::GameSelector(AbstractClient *_client, } buttonLayout->setAlignment(Qt::AlignTop); + hostErrorLabel = new QLabel(this); + hostErrorLabel->setObjectName("hostGameErrorLabel"); + hostErrorLabel->setWordWrap(true); + hostErrorLabel->setVisible(false); + QVBoxLayout *mainLayout = new QVBoxLayout; if (showFilters && restoresettings) { mainLayout->addWidget(quickFilterToolBar); } + mainLayout->addWidget(hostErrorLabel); mainLayout->addWidget(gameListView); mainLayout->addLayout(buttonLayout); @@ -266,6 +360,8 @@ void GameSelector::actCreate() return; } + clearHostError(); + // Heap-allocate the dialog: if this selector (or its room tab) is destroyed while the // dialog is open, e.g. on server disconnect, Qt deletes its children. A stack-allocated // child would then be deleted by Qt and crash. @@ -275,6 +371,128 @@ void GameSelector::actCreate() dlg->exec(); } +void GameSelector::actQuickHost() +{ + if (room == nullptr) { + qWarning() << "Attempted to create a game, but the room was null"; + return; + } + + clearHostError(); + + auto &gameSettings = SettingsCache::instance().game(); + const bool registered = tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsRegistered; + const GameCreationOptions options = + gameSettings.getRememberGameSettings() + ? GameCreationOptions::fromSettings(gameSettings, room->getGameTypes(), registered) + : GameCreationOptions::defaultsFor(registered); + + PendingCommand *pend = room->prepareRoomCommand(options.toCreateGameCommand()); + connect(pend, &PendingCommand::finished, this, &GameSelector::checkQuickHostResponse); + room->sendRoomCommand(pend); + + setHostCommandPending(true); +} + +void GameSelector::checkQuickHostResponse(const Response &response) +{ + setHostCommandPending(false); + + switch (response.response_code()) { + case Response::RespOk: + clearHostError(); + break; + case Response::RespContextError: + showHostError(tr("You already host the maximum number of games.")); + break; + case Response::RespInternalError: + showHostError(tr("The server could not create the game.")); + break; + default: + showHostError(tr("Could not create the game.")); + break; + } +} + +void GameSelector::showHostError(const QString &message) +{ + hostErrorLabel->setText(message); + hostErrorLabel->setAccessibleName(message); + hostErrorLabel->setVisible(true); + + // The label appears above the game list without taking focus, so announce it: a screen + // reader would otherwise never reach the message after the button press. + QAccessibleEvent alertEvent(hostErrorLabel, QAccessible::Alert); + QAccessible::updateAccessibility(&alertEvent); +} + +void GameSelector::clearHostError() +{ + hostErrorLabel->clear(); + hostErrorLabel->setAccessibleName(QString()); + hostErrorLabel->setVisible(false); +} + +void GameSelector::applyHostGameStyle() +{ + if (hostGameButton == nullptr || hostGameMenu == nullptr || hostGameMenuButton == nullptr || + hostGameSettingsButton == nullptr) { + return; + } + + // The primary button always quick-hosts. The native macOS style has no split-button + // rendering (a QToolButton menu becomes a second square bezel), so the secondary + // action is a plain sibling button there; every other style gets the split arrow. + if (usesMacStyle()) { + hostGameMenuButton->setMenu(nullptr); + hostGameMenuButton->setVisible(false); + hostGameSettingsButton->setVisible(true); + } else { + hostGameMenuButton->setMenu(hostGameMenu); + hostGameMenuButton->setVisible(true); + hostGameSettingsButton->setVisible(false); + } + + // The appearance settings can switch the style while the room is open. + updateHostGameToolTip(); +} + +void GameSelector::changeEvent(QEvent *event) +{ + if (event->type() == QEvent::StyleChange) { + applyHostGameStyle(); + } + QGroupBox::changeEvent(event); +} + +void GameSelector::setHostCommandPending(bool pending) +{ + hostCommandPending = pending; + // A join may still be in flight: its response enables these controls again, so keep them + // disabled until that response arrives instead of re-enabling on the host response alone. + updateHostControlsEnabled(); +} + +void GameSelector::updateHostControlsEnabled() +{ + const bool hostControlsEnabled = !hostCommandPending && !joinCommandPending; + if (hostGameAction) { + hostGameAction->setEnabled(hostControlsEnabled); + } + if (hostGameButton) { + hostGameButton->setEnabled(hostControlsEnabled); + } + if (hostGameMenuButton) { + hostGameMenuButton->setEnabled(hostControlsEnabled); + } + if (hostGameSettingsButton) { + hostGameSettingsButton->setEnabled(hostControlsEnabled); + } + if (gameSettingsAction) { + gameSettingsAction->setEnabled(hostControlsEnabled); + } +} + void GameSelector::checkResponse(const Response &response) { // NB: We re-enable buttons for the currently selected game, which may not @@ -289,6 +507,9 @@ void GameSelector::checkResponse(const Response &response) // Ideally we should have a way to figure out if the current game is the // same as the one we are getting a response for, but it's probably not // worth the trouble. + // The join response is the one that re-enables the buttons; clear the join flag first so + // the host controls become usable again unless a quick host is still in flight. + joinCommandPending = false; enableButtons(); switch (response.response_code()) { @@ -486,9 +707,8 @@ bool GameSelector::joinGameById(const int gameId, const bool asSpectator) void GameSelector::disableButtons() { - if (createButton) { - createButton->setEnabled(false); - } + joinCommandPending = true; + updateHostControlsEnabled(); joinButton->setEnabled(false); spectateButton->setEnabled(false); @@ -496,9 +716,7 @@ void GameSelector::disableButtons() void GameSelector::enableButtons() { - if (createButton) { - createButton->setEnabled(true); - } + updateHostControlsEnabled(); // Enable buttons for the currently selected game enableButtonsForIndex(gameListView->currentIndex()); @@ -521,8 +739,27 @@ void GameSelector::retranslateUi() { filterButton->setText(tr("&Filter games")); clearFilterButton->setText(tr("C&lear filter")); - if (createButton) { - createButton->setText(tr("C&reate")); + if (hostGameAction) { + hostGameAction->setText(tr("Host with last settings")); + if (hostGameButton) { + // A QPushButton does not draw text from an action; the button keeps the short + // label while the menu item spells out what the remembered settings do. + hostGameButton->setText(tr("Host game")); + } + updateHostGameToolTip(); + } + if (gameSettingsAction) { + gameSettingsAction->setText(tr("Game settings…")); + if (hostGameSettingsButton) { + // A QPushButton does not draw text from an action; on macOS this button is the + // secondary action, so it carries the same label. + hostGameSettingsButton->setText(tr("Game settings…")); + } + if (hostGameMenuButton) { + // The split arrow has no text of its own; name it for the menu it opens. + hostGameMenuButton->setToolTip(tr("More host options")); + hostGameMenuButton->setAccessibleName(tr("More host options")); + } } joinButton->setText(tr("&Join")); joinAsJudgeButton->setText(tr("Join as judge")); @@ -532,6 +769,36 @@ void GameSelector::retranslateUi() updateTitle(); } +void GameSelector::refreshShortcuts() +{ + if (hostGameAction) { + hostGameAction->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("GameSelector/aQuickHostGame")); + updateHostGameToolTip(); + } + if (gameSettingsAction) { + gameSettingsAction->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("GameSelector/aReviewGame")); + } +} + +void GameSelector::updateHostGameToolTip() +{ + const QString shortcut = SettingsCache::instance().shortcuts().getShortcutString("GameSelector/aQuickHostGame"); + const QString quickHostTooltip = shortcut.isEmpty() + ? tr("Host a game with your last-used settings") + : tr("Host a game with your last-used settings (%1)").arg(shortcut); + // The tooltip lives on the action for the shortcut and on the button for the mouse, + // since a QPushButton does not inherit an action's tooltip. + if (hostGameAction) { + hostGameAction->setToolTip(quickHostTooltip); + } + if (hostGameButton) { + hostGameButton->setToolTip(quickHostTooltip); + } + if (hostGameSettingsButton) { + hostGameSettingsButton->setToolTip(tr("Open the game settings before hosting")); + } +} + void GameSelector::processGameInfo(const ServerInfo_Game &info) { gameListModel->updateGameList(info); diff --git a/cockatrice/src/interface/widgets/server/game_selector.h b/cockatrice/src/interface/widgets/server/game_selector.h index e3407abea..72c6fabfe 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.h +++ b/cockatrice/src/interface/widgets/server/game_selector.h @@ -14,7 +14,11 @@ class QTreeView; class GamesModel; class GamesProxyModel; +class QLabel; +class QMenu; class QPushButton; +class QToolButton; +class QAction; class AbstractClient; class TabSupervisor; class TabRoom; @@ -64,6 +68,17 @@ private slots: */ void actCreate(); + /** + * @brief Creates a game immediately from the remembered settings, without a dialog. + */ + void actQuickHost(); + + /** + * @brief Reports a failed quick-host request inline instead of as a modal box. + * @param response The server response to the create command. + */ + void checkQuickHostResponse(const Response &response); + /** * @brief Joins the currently selected game as a player. */ @@ -127,6 +142,15 @@ signals: */ void gameJoined(int gameId); +protected: + /** + * @brief Rebuilds the host control when the application style changes. + * + * The appearance settings can switch between the native macOS style and a + * cross-platform style while the room is open, and the host control follows. + */ + void changeEvent(QEvent *event) override; + private: AbstractClient *client; /**< The network client used to communicate with the server. */ TabSupervisor *tabSupervisor; /**< Reference to TabSupervisor for managing tabs and rooms. */ @@ -138,13 +162,20 @@ private: GameSelectorQuickFilterToolBar *quickFilterToolBar; - QPushButton *filterButton; /**< Button to open the filter dialog. */ - QPushButton *clearFilterButton; /**< Button to clear active filters. */ - QPushButton *createButton; /**< Button to create a new game (only if room is set). */ - QPushButton *joinButton; /**< Button to join the selected game. */ - QPushButton *joinAsJudgeButton; /**< Button to join the selected game as a judge. */ - QPushButton *spectateButton; /**< Button to spectate the selected game. */ - QPushButton *joinAsJudgeSpectatorButton; /**< Button to join the selected game as a spectating judge. */ + QPushButton *filterButton; /**< Button to open the filter dialog. */ + QPushButton *clearFilterButton; /**< Button to clear active filters. */ + QPushButton *hostGameButton = nullptr; /**< Host button, in the same family as the join buttons. */ + QPushButton *hostGameSettingsButton = nullptr; /**< Opens the host form; a sibling button on macOS. */ + QToolButton *hostGameMenuButton = nullptr; /**< Split arrow that opens the host menu (non-macOS styles only). */ + QWidget *hostGameButtonWidget = nullptr; /**< Container that keeps the split button and its arrow together. */ + QMenu *hostGameMenu = nullptr; /**< Menu behind the split arrow (non-macOS styles only). */ + QAction *hostGameAction = nullptr; /**< Quick-host action, carrying the keyboard shortcut. */ + QAction *gameSettingsAction = nullptr; /**< Opens the host form with the current settings. */ + QLabel *hostErrorLabel; /**< Inline failure message for a quick-host request. */ + QPushButton *joinButton; /**< Button to join the selected game. */ + QPushButton *joinAsJudgeButton; /**< Button to join the selected game as a judge. */ + QPushButton *spectateButton; /**< Button to spectate the selected game. */ + QPushButton *joinAsJudgeSpectatorButton; /**< Button to join the selected game as a spectating judge. */ const bool showFilters; /**< Determines whether filter buttons are displayed. */ GameTypeMap gameTypeMap; /**< Mapping of game types for the current room. */ @@ -152,6 +183,28 @@ private: QTimer filterSaveDebounceTimer; /**< Delays persisting quick-filter edits so typing does not rewrite the settings file on every keystroke. */ + bool hostCommandPending = false; /**< True while a quick-host command is in flight. */ + bool joinCommandPending = false; /**< True while a join/spectate command is in flight. */ + + /** @brief Shows a quick-host failure above the game list. */ + void showHostError(const QString &message); + /** @brief Hides the quick-host failure message. */ + void clearHostError(); + /** + * @brief Rebuilds the host control for the active style. + * + * The primary button always quick-hosts. Every other style gets the split button, + * whose arrow opens the menu; the native macOS style has no split control, so it + * gets a plain sibling button for the secondary action instead. + */ + void applyHostGameStyle(); + /** @brief Marks a quick-host command as in flight or finished, then updates the host controls. */ + void setHostCommandPending(bool pending); + /** @brief Applies the in-flight state of both host and join commands to the host controls. */ + void updateHostControlsEnabled(); + /** @brief Keeps the host control tooltips in sync with the current shortcut and style. */ + void updateHostGameToolTip(); + /** * @brief Updates the widget title to reflect the current number of displayed games. * @@ -222,6 +275,11 @@ public: */ void retranslateUi(); + /** + * @brief Re-reads the quick-host and review shortcuts after the user rebinds them. + */ + void refreshShortcuts(); + /** * @brief Updates or adds a game entry in the list. * @param info The ServerInfo_Game object containing information about the game to update. diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index 329732c64..1973a0747 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -113,7 +113,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, QMap tempMap; tempMap.insert(info.room_id(), gameTypes); - gameSelector = new GameSelector(client, tabSupervisor, this, QMap(), tempMap, true, true); + gameSelector = new GameSelector(client, tabSupervisor, this, QMap(), tempMap, true, true, this); userListPanel = new UserListPanelWidget(tabSupervisor, client, this); userListPanel->bind(tabSupervisor->getUserListManager()); @@ -720,6 +720,7 @@ void TabRoom::processRemoveMessagesEvent(const Event_RemoveMessages &event) void TabRoom::refreshShortcuts() { aClearChat->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("tab_room/aClearChat")); + gameSelector->refreshShortcuts(); } void TabRoom::addMentionTag(QString mentionTag) diff --git a/tests/game_creation_options_test.cpp b/tests/game_creation_options_test.cpp index e4a7996cb..cd8344ba9 100644 --- a/tests/game_creation_options_test.cpp +++ b/tests/game_creation_options_test.cpp @@ -185,6 +185,26 @@ TEST(GameCreationOptions, DescriptionWhitespaceIsSimplified) EXPECT_EQ(cmd.description(), "a b"); } +TEST(GameCreationOptions, DefaultsForRegisteredHostEnforceRegistration) +{ + const GameCreationOptions registered = GameCreationOptions::defaultsFor(true); + + EXPECT_TRUE(registered.onlyRegistered); + EXPECT_FALSE(registered.onlyBuddies); + EXPECT_EQ(registered.maxPlayers, 2); + EXPECT_TRUE(registered.spectatorsAllowed); + EXPECT_EQ(registered.startingLifeTotal, 20); + EXPECT_TRUE(registered.gameTypeIds.isEmpty()); +} + +TEST(GameCreationOptions, DefaultsForUnregisteredHostAllowOpenJoining) +{ + const GameCreationOptions unregistered = GameCreationOptions::defaultsFor(false); + + EXPECT_FALSE(unregistered.onlyRegistered); + EXPECT_FALSE(unregistered.onlyBuddies); +} + TEST(GameCreationOptions, FromSettingsCopiesEveryRememberedField) { const FakeGameSettings settings{GameCreationOptions{.description = "Remembered game",