From 5968e5f40a18455fe57907be8526633c20eaf771 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Mon, 17 Aug 2026 22:31:59 +0200 Subject: [PATCH] dismiss a dialog with Q, and refuse to dismiss the drop dialog Three dialogs take Q as a second way out beside Escape: the recipe/schematic selection dialog and the blueprint selection dialog close outright, and the layout configuration dialog steps out one level per press -- the module being placed, then remove mode, then the session. Both of those mode exits now go through the handler the Remove button uses, extracted from a lambda into onRemoveButtonClicked(), so the key and the button cannot leave different state behind. The schematic choice dialog goes the other way and declines reject(). It had no close button but Escape still closed it, and the caller then applied choiceIndex 0 -- awarding whichever option happened to be first. Refusing reject() covers Escape, Alt+F4, and the window manager together, since all three funnel through it. It is also the only dialog whose dismissal would strand state: the poll that opened it does not reopen it while the choices stay pending, so a drop dismissed is a drop lost. The key itself is spelled once in DialogDismiss.h rather than in three key handlers. It stays out of the ControlAction table on purpose: that table answers what an input does in the player's current situation, and a dialog has none -- it holds focus and takes the key whatever the world is doing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x --- src/ui/BlueprintSelectionDialog.cpp | 14 ++++++++ src/ui/BlueprintSelectionDialog.h | 7 ++-- src/ui/CMakeLists.txt | 1 + src/ui/DialogDismiss.h | 19 +++++++++++ src/ui/MainWindow.cpp | 5 +++ src/ui/RecipeSelectionDialog.cpp | 13 ++++++++ src/ui/RecipeSelectionDialog.h | 5 ++- src/ui/SchematicChoiceDialog.cpp | 6 ++++ src/ui/SchematicChoiceDialog.h | 11 +++++++ src/ui/ShipLayoutDialog.cpp | 51 +++++++++++++++++++---------- src/ui/ShipLayoutDialog.h | 4 +++ 11 files changed, 115 insertions(+), 21 deletions(-) create mode 100644 src/ui/DialogDismiss.h 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();