diff --git a/src/ui/BlueprintSelectionDialog.cpp b/src/ui/BlueprintSelectionDialog.cpp index bc5a321..3f21aaa 100644 --- a/src/ui/BlueprintSelectionDialog.cpp +++ b/src/ui/BlueprintSelectionDialog.cpp @@ -28,6 +28,7 @@ #include "Blueprint.h" #include "BlueprintLibrary.h" +#include "DialogDismiss.h" #include "IconCaption.h" #include "ItemIconCache.h" @@ -236,6 +237,19 @@ std::optional BlueprintSelectionDialog::getChosenIndex() const return m_chosenIndex; } +void BlueprintSelectionDialog::keyPressEvent(QKeyEvent* event) +{ + // Q closes with no card picked, exactly as the close button does. A build mode + // running underneath is left alone: the dialog took the key, not the world + // (REQ-UI-DIALOG-DISMISS, REQ-UI-BLUEPRINT-DIALOG). + if (isDialogDismissKey(*event)) + { + reject(); + return; + } + QDialog::keyPressEvent(event); +} + void BlueprintSelectionDialog::rebuildGrid() { // deleteLater, not delete: this runs from a delete button's own clicked signal, and diff --git a/src/ui/BlueprintSelectionDialog.h b/src/ui/BlueprintSelectionDialog.h index 04528fd..d53041b 100644 --- a/src/ui/BlueprintSelectionDialog.h +++ b/src/ui/BlueprintSelectionDialog.h @@ -16,8 +16,8 @@ class QWidget; // Clicking a card accepts the dialog and reports that blueprint's index; the caller // enters placement mode afterwards, so the dialog is already closed by then // (REQ-UI-BLUEPRINT-CARD). Deleting acts on the library immediately and leaves the -// dialog open (REQ-UI-BLUEPRINT-DELETE). Escape and the close button dismiss it with -// no other effect. +// dialog open (REQ-UI-BLUEPRINT-DELETE). Escape, Q, and the close button dismiss it +// with no other effect (REQ-UI-DIALOG-DISMISS). class BlueprintSelectionDialog : public QDialog { Q_OBJECT @@ -29,6 +29,9 @@ public: std::optional getChosenIndex() const; +protected: + void keyPressEvent(QKeyEvent* event) override; + private: void rebuildGrid(); void onCardClicked(int index); diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 7ea9040..6e7a555 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -18,6 +18,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/SelectionBounds.h ${CMAKE_CURRENT_SOURCE_DIR}/ControlsPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/ControlActionText.h + ${CMAKE_CURRENT_SOURCE_DIR}/DialogDismiss.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h diff --git a/src/ui/DialogDismiss.h b/src/ui/DialogDismiss.h new file mode 100644 index 0000000..16d9c8c --- /dev/null +++ b/src/ui/DialogDismiss.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +// REQ-UI-DIALOG-DISMISS: Q dismisses an open dialog, beside the Escape that QDialog +// already handles. The key is spelled here rather than in each dialog's key handler, so +// the dialogs that take it cannot drift apart from one another. +// +// Ctrl must not be held, matching how the game world's table separates a chord from the +// bare key (resolveKeyAction in lib/core/ControlAction.cpp). This deliberately stays out +// of that table: the table answers what an input does in the player's current situation, +// and a dialog has no situation -- it holds focus and takes the key whatever the world +// beneath it is doing. +inline bool isDialogDismissKey(const QKeyEvent& event) +{ + return event.key() == Qt::Key_Q + && (event.modifiers() & Qt::ControlModifier) == 0; +} diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index e8d27b4..17c3974 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -228,6 +228,11 @@ void MainWindow::handleEvent(std::shared_ptr command = std::make_shared(); command->choiceIndex = dialog.getChosenIndex(); diff --git a/src/ui/RecipeSelectionDialog.cpp b/src/ui/RecipeSelectionDialog.cpp index a72d811..e647d80 100644 --- a/src/ui/RecipeSelectionDialog.cpp +++ b/src/ui/RecipeSelectionDialog.cpp @@ -11,6 +11,7 @@ #include "Building.h" #include "BuildingType.h" +#include "DialogDismiss.h" #include "DisplayName.h" #include "GameConfig.h" #include "OptionButton.h" @@ -183,6 +184,18 @@ std::optional RecipeSelectionDialog::getChosenId() const return m_chosenId; } +void RecipeSelectionDialog::keyPressEvent(QKeyEvent* event) +{ + // Q dismisses, leaving the recipe as it was -- the same nothing that Escape and the + // close button do, since no option was clicked (REQ-UI-DIALOG-DISMISS). + if (isDialogDismissKey(*event)) + { + reject(); + return; + } + QDialog::keyPressEvent(event); +} + void RecipeSelectionDialog::onOptionClicked(int index) { if (index >= 0 && index < static_cast(m_optionIds.size())) diff --git a/src/ui/RecipeSelectionDialog.h b/src/ui/RecipeSelectionDialog.h index 897d3f6..1dd2a3a 100644 --- a/src/ui/RecipeSelectionDialog.h +++ b/src/ui/RecipeSelectionDialog.h @@ -40,7 +40,7 @@ std::vector buildRecipeSelectionOptions( // Modal dialog listing the options in one vertical column (REQ-UI-SELECT-OPTIONS). The // game is paused by the caller while it is open. Clicking an option selects it -// and closes the dialog; dismissing it (close/Esc) leaves no choice. +// and closes the dialog; dismissing it (close/Esc/Q) leaves no choice. class RecipeSelectionDialog : public QDialog { Q_OBJECT @@ -53,6 +53,9 @@ public: std::optional getChosenId() const; +protected: + void keyPressEvent(QKeyEvent* event) override; + private: void onOptionClicked(int index); diff --git a/src/ui/SchematicChoiceDialog.cpp b/src/ui/SchematicChoiceDialog.cpp index 4f7f53e..5931c1a 100644 --- a/src/ui/SchematicChoiceDialog.cpp +++ b/src/ui/SchematicChoiceDialog.cpp @@ -167,6 +167,12 @@ int SchematicChoiceDialog::getChosenIndex() const return m_chosenIndex; } +void SchematicChoiceDialog::reject() +{ + // Deliberately empty: the dialog stays open until an option is clicked + // (REQ-DEF-SCHEMATIC-DROP). The game is paused meanwhile, so nothing waits on it. +} + void SchematicChoiceDialog::onOptionClicked(int index) { m_chosenIndex = index; diff --git a/src/ui/SchematicChoiceDialog.h b/src/ui/SchematicChoiceDialog.h index 745f97a..386b71c 100644 --- a/src/ui/SchematicChoiceDialog.h +++ b/src/ui/SchematicChoiceDialog.h @@ -10,6 +10,9 @@ struct RecipesConfig; class BuildingIconCache; class ItemIconCache; +// The drop's choice dialog (REQ-DEF-SCHEMATIC-DROP). Unlike every other dialog it +// cannot be dismissed: clicking an option is the only way out, so getChosenIndex() +// always names an option the player picked, and exec() only ever returns Accepted. class SchematicChoiceDialog : public QDialog { Q_OBJECT @@ -24,6 +27,14 @@ public: int getChosenIndex() const; +public slots: + // Refuses the dismissal (REQ-DEF-SCHEMATIC-DROP): the drop is a reward the player + // has earned, and leaving without choosing would either forfeit it or award the + // option that happens to be first. Escape, Alt+F4, and the window manager's close + // all funnel through QDialog::reject(), so declining it here turns away every one of + // them at once rather than swallowing keys one at a time. + void reject() override; + private: void onOptionClicked(int index); diff --git a/src/ui/ShipLayoutDialog.cpp b/src/ui/ShipLayoutDialog.cpp index caf9b9a..e25d931 100644 --- a/src/ui/ShipLayoutDialog.cpp +++ b/src/ui/ShipLayoutDialog.cpp @@ -4,6 +4,7 @@ #include #include +#include "DialogDismiss.h" #include "DisplayName.h" #include "OptionButton.h" #include "ProductionRules.h" @@ -575,24 +576,8 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config, ++row; } buttonGrid->addWidget(m_removeButton, row, 0, 1, kCols); - connect(m_removeButton, &QPushButton::clicked, this, [this]() { - if (m_removeMode) - { - m_removeMode = false; - m_removeButton->setChecked(false); - } - else - { - for (QPushButton* btn : m_moduleButtons) - { - if (btn) { btn->setChecked(false); } - } - m_activeModuleIndex = std::nullopt; - m_removeMode = true; - m_removeButton->setChecked(true); - } - updateGridWidget(); - }); + connect(m_removeButton, &QPushButton::clicked, + this, &ShipLayoutDialog::onRemoveButtonClicked); centerLayout->addLayout(buttonGrid); centerLayout->addStretch(); @@ -697,6 +682,16 @@ void ShipLayoutDialog::keyPressEvent(QKeyEvent* event) } updateGridWidget(); } + else if (isDialogDismissKey(*event)) + { + // Q backs out one level per press, as it does in the game world: the module + // being placed, then remove mode, then the dialog itself, which discards the + // session (REQ-UI-DIALOG-DISMISS). Both mode exits go through the handler the + // button uses, so the key and the button can never leave different state behind. + if (m_activeModuleIndex.has_value()) { onModuleButtonClicked(*m_activeModuleIndex); } + else if (m_removeMode) { onRemoveButtonClicked(); } + else { onCancel(); } + } else { QDialog::keyPressEvent(event); @@ -723,6 +718,26 @@ void ShipLayoutDialog::onModuleButtonClicked(int index) updateGridWidget(); } +void ShipLayoutDialog::onRemoveButtonClicked() +{ + if (m_removeMode) + { + m_removeMode = false; + m_removeButton->setChecked(false); + } + else + { + for (QPushButton* btn : m_moduleButtons) + { + if (btn) { btn->setChecked(false); } + } + m_activeModuleIndex = std::nullopt; + m_removeMode = true; + m_removeButton->setChecked(true); + } + updateGridWidget(); +} + void ShipLayoutDialog::onConfirm() { ShipLayoutConfig layout; diff --git a/src/ui/ShipLayoutDialog.h b/src/ui/ShipLayoutDialog.h index d1029a1..e9225e8 100644 --- a/src/ui/ShipLayoutDialog.h +++ b/src/ui/ShipLayoutDialog.h @@ -46,6 +46,10 @@ signals: private slots: void onModuleButtonClicked(int index); + // Enters remove mode, or leaves it when it is already active (REQ-MOD-REMOVE). + // Reached by the Remove button and by the Q key, which leaves the mode on its way + // out of the dialog (REQ-UI-DIALOG-DISMISS). + void onRemoveButtonClicked(); void onConfirm(); void onCancel();