diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 2a258ad..3ab7349 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -5,9 +5,11 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/VisualsConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.h ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h + ${CMAKE_CURRENT_SOURCE_DIR}/MessageDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/ModalDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/ModalLayer.h ${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.h + ${CMAKE_CURRENT_SOURCE_DIR}/NameInputDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h ${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.h ${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.h @@ -44,8 +46,10 @@ SET(SRCS ${SRCS} ${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.cpp ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/MessageDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ModalDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ModalLayer.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/NameInputDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp ${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.cpp diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index f6c9a4d..64e2d70 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -10,10 +10,7 @@ #include #include #include -#include -#include #include -#include #include #include @@ -35,8 +32,10 @@ #include "ShipLayoutDialog.h" #include "BuildingIconCache.h" #include "ItemIconCache.h" +#include "MessageDialog.h" #include "ModalLayer.h" #include "ModalPauseScope.h" +#include "NameInputDialog.h" #include "Simulation.h" #include "Tick.h" #include "VisualsLoader.h" @@ -257,17 +256,17 @@ void MainWindow::handleEvent(std::shared_ptr /*e { ModalPauseScope pause(*m_gameWorldView); - ModalLayerHold dim(*m_modalLayer); - QMessageBox box(this); - box.setWindowTitle(tr("Paused")); - QPushButton* continueBtn = box.addButton(tr("Continue"), QMessageBox::AcceptRole); - QPushButton* restartBtn = box.addButton(tr("Restart"), QMessageBox::ResetRole); - QPushButton* quitBtn = box.addButton(tr("Quit"), QMessageBox::DestructiveRole); - box.setEscapeButton(continueBtn); - box.exec(); + MessageDialog box(tr("Paused"), QString(), m_modalLayer); + const int continueIndex = box.addButton(tr("Continue")); + const int restartIndex = box.addButton(tr("Restart")); + const int quitIndex = box.addButton(tr("Quit")); + // Escape stands for Continue, as it did when this was a system box + // (REQ-UI-GAME-MENU). + box.setEscapeButtonIndex(continueIndex); + m_modalLayer->execute(box); - QAbstractButton* clicked = box.clickedButton(); - if (clicked == restartBtn) + const std::optional clicked = box.getClickedButtonIndex(); + if (clicked == restartIndex) { std::optional newConfig = reloadConfig(); if (!newConfig.has_value()) @@ -285,7 +284,7 @@ void MainWindow::handleEvent(std::shared_ptr /*e EventManager::getInstance()->sendEventImmediately( std::make_shared(command)); } - else if (clicked == quitBtn) + else if (clicked == quitIndex) { pause.release(); close(); @@ -476,14 +475,13 @@ void MainWindow::handleEvent(std::shared_ptr ModalPauseScope pause(*m_gameWorldView); ModalLayerHold dim(*m_modalLayer); - bool ok = false; - const QString name = QInputDialog::getText( - this, tr("Create Blueprint"), tr("Blueprint name:"), QLineEdit::Normal, - QString(), &ok); + NameInputDialog nameDialog(tr("Create Blueprint"), tr("Blueprint name:"), + m_modalLayer); + const int result = m_modalLayer->execute(nameDialog); // Cancel, Escape, or an empty name: no blueprint, and no selection dialog. - if (!ok || name.trimmed().isEmpty()) { return; } + if (result != QDialog::Accepted || nameDialog.getName().isEmpty()) { return; } - m_blueprintLibrary->saveSelectionAs(name.trimmed()); + m_blueprintLibrary->saveSelectionAs(nameDialog.getName()); showBlueprintSelectionDialog(); } @@ -513,17 +511,18 @@ void MainWindow::handleEvent(std::shared_ptr /*event*/) const int minutes = totalSeconds / 60; const int seconds = totalSeconds % 60; - ModalLayerHold dim(*m_modalLayer); - QMessageBox box(this); - box.setWindowTitle(tr("Game Over")); - box.setText(tr("HQ destroyed!\nSurvival time: %1:%2") - .arg(minutes, 2, 10, QChar('0')) - .arg(seconds, 2, 10, QChar('0'))); - QPushButton* restartBtn = box.addButton(tr("Restart"), QMessageBox::AcceptRole); - box.addButton(tr("Quit"), QMessageBox::RejectRole); - box.exec(); + MessageDialog box(tr("Game Over"), + tr("HQ destroyed!\nSurvival time: %1:%2") + .arg(minutes, 2, 10, QChar('0')) + .arg(seconds, 2, 10, QChar('0')), + m_modalLayer); + const int restartIndex = box.addButton(tr("Restart")); + const int quitIndex = box.addButton(tr("Quit")); + // Escape quits, which is where the system box's reject role sent it. + box.setEscapeButtonIndex(quitIndex); + m_modalLayer->execute(box); - if (box.clickedButton() == restartBtn) + if (box.getClickedButtonIndex() == restartIndex) { std::optional newConfig = reloadConfig(); if (!newConfig.has_value()) @@ -550,17 +549,18 @@ void MainWindow::handleEvent(std::shared_ptr /*event*/) const int minutes = totalSeconds / 60; const int seconds = totalSeconds % 60; - ModalLayerHold dim(*m_modalLayer); - QMessageBox box(this); - box.setWindowTitle(tr("Won!")); - box.setText(tr("You collected all artifacts!\nSurvival time: %1:%2") - .arg(minutes, 2, 10, QChar('0')) - .arg(seconds, 2, 10, QChar('0'))); - QPushButton* restartBtn = box.addButton(tr("Restart"), QMessageBox::AcceptRole); - box.addButton(tr("Quit"), QMessageBox::RejectRole); - box.exec(); + MessageDialog box(tr("Won!"), + tr("You collected all artifacts!\nSurvival time: %1:%2") + .arg(minutes, 2, 10, QChar('0')) + .arg(seconds, 2, 10, QChar('0')), + m_modalLayer); + const int restartIndex = box.addButton(tr("Restart")); + const int quitIndex = box.addButton(tr("Quit")); + // Escape quits, which is where the system box's reject role sent it. + box.setEscapeButtonIndex(quitIndex); + m_modalLayer->execute(box); - if (box.clickedButton() == restartBtn) + if (box.getClickedButtonIndex() == restartIndex) { std::optional newConfig = reloadConfig(); if (!newConfig.has_value()) diff --git a/src/ui/MessageDialog.cpp b/src/ui/MessageDialog.cpp new file mode 100644 index 0000000..8ff3715 --- /dev/null +++ b/src/ui/MessageDialog.cpp @@ -0,0 +1,75 @@ +#include "MessageDialog.h" + +#include +#include +#include +#include + +namespace +{ + const int kSpacingPx = 8; +} + +MessageDialog::MessageDialog(const QString& title, const QString& text, QWidget* parent) + : ModalDialog(parent) + , m_buttonLayout(nullptr) + , m_buttonCount(0) +{ + QVBoxLayout* mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(kSpacingPx, kSpacingPx, kSpacingPx, kSpacingPx); + mainLayout->setSpacing(kSpacingPx); + + if (!text.isEmpty()) + { + QLabel* textLabel = new QLabel(text, this); + mainLayout->addWidget(textLabel); + } + + // The buttons sit at the right of their row, the side a dialog is confirmed from. + m_buttonLayout = new QHBoxLayout(); + m_buttonLayout->setSpacing(kSpacingPx); + m_buttonLayout->addStretch(); + mainLayout->addLayout(m_buttonLayout); + + // Last, so it becomes the first row (REQ-UI-MODAL-CHROME). No close button: closing + // is one of the buttons here, or nothing at all. + addHeader(mainLayout, title, false); +} + +int MessageDialog::addButton(const QString& caption) +{ + const int index = m_buttonCount++; + QPushButton* button = new QPushButton(caption, this); + m_buttonLayout->addWidget(button); + connect(button, &QPushButton::clicked, this, [this, index]() { + onButtonClicked(index); + }); + return index; +} + +void MessageDialog::setEscapeButtonIndex(int index) +{ + m_escapeButtonIndex = index; +} + +std::optional MessageDialog::getClickedButtonIndex() const +{ + return m_clickedButtonIndex; +} + +void MessageDialog::reject() +{ + // Escape stands for a button rather than for a dismissal of its own, so the caller + // reads one answer whichever way the player gave it (REQ-UI-GAME-MENU). + if (!m_escapeButtonIndex.has_value()) + { + return; + } + onButtonClicked(*m_escapeButtonIndex); +} + +void MessageDialog::onButtonClicked(int index) +{ + m_clickedButtonIndex = index; + accept(); +} diff --git a/src/ui/MessageDialog.h b/src/ui/MessageDialog.h new file mode 100644 index 0000000..b78a3dc --- /dev/null +++ b/src/ui/MessageDialog.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include + +#include + +#include "ModalDialog.h" + +class QHBoxLayout; +class QLabel; + +// The game's own message box (REQ-UI-MODAL-CHROME): a title, an optional line or two of +// text, and a row of buttons the caller names. It stands in for QMessageBox at the three +// places the player is asked to decide something -- the escape menu (REQ-UI-GAME-MENU) +// and the game-over and win screens (REQ-HQ-GAME-OVER, REQ-WIN-SCREEN) -- so that those +// read as part of the game rather than as system alerts. +// +// The caller identifies buttons by the index addButton() hands back, and asks +// getClickedButtonIndex() afterwards; the QDialog result code says only whether a button +// was clicked at all. Q and a click outside do not dismiss it: every button here is a +// decision, and there is no "no change" among them to fall back on. +class MessageDialog : public ModalDialog +{ + Q_OBJECT + +public: + // text may be empty, for a dialog that is a question its buttons already state. + MessageDialog(const QString& title, const QString& text, QWidget* parent = nullptr); + + // Appends a button and returns its index, left to right. + int addButton(const QString& caption); + + // Which button Escape stands for. Without one, Escape does nothing -- a dialog whose + // buttons all commit to something has no dismissal to offer. + void setEscapeButtonIndex(int index); + + std::optional getClickedButtonIndex() const; + +public slots: + void reject() override; + +private: + void onButtonClicked(int index); + + QHBoxLayout* m_buttonLayout; + int m_buttonCount; + std::optional m_escapeButtonIndex; + std::optional m_clickedButtonIndex; +}; diff --git a/src/ui/ModalLayer.cpp b/src/ui/ModalLayer.cpp index df3a472..59bffce 100644 --- a/src/ui/ModalLayer.cpp +++ b/src/ui/ModalLayer.cpp @@ -1,6 +1,6 @@ #include "ModalLayer.h" -#include +#include #include #include @@ -12,6 +12,20 @@ #include "ModalDialog.h" +ModalLayer* ModalLayer::findFor(const QWidget& widget) +{ + for (QWidget* candidate = widget.parentWidget(); candidate != nullptr; + candidate = candidate->parentWidget()) + { + ModalLayer* layer = qobject_cast(candidate); + if (layer != nullptr) + { + return layer; + } + } + return nullptr; +} + ModalLayer::ModalLayer(const QColor& dimColor, QWidget* parent) : QWidget(parent) , m_dimColor(dimColor) @@ -30,13 +44,31 @@ int ModalLayer::execute(ModalDialog& content, const QRect& anchorRect) host->setWidgetResizable(false); host->setWidget(&content); - m_stack.push_back(&content); + // Resolved before the new modal joins the stack, so a null rect means the modal it + // was opened from -- the Create Blueprint dialog opens on the layout dialog beneath + // it (REQ-UI-PANEL-MODAL). + const QRect anchor = !anchorRect.isNull() ? anchorRect + : (m_stack.empty() ? rect() + : m_stack.back().host->geometry()); + + m_stack.push_back(HostedModal{ &content, host }); updateVisibility(); raise(); - place(*host, content, anchorRect); + place(*host, content, anchor); host->show(); content.show(); - content.setFocus(); + + // The modal's own focus widget where it has one -- a name dialog puts the caret in + // its line edit -- and the modal itself otherwise, so keys reach it and not the game + // world behind (REQ-UI-MODAL-CHROME). + if (content.focusWidget() != nullptr) + { + content.focusWidget()->setFocus(); + } + else + { + content.setFocus(); + } // The dialog's own loop, run here rather than by QDialog::exec(), so the layer knows // what is open and can place it, dim behind it, and take the clicks beside it. @@ -52,7 +84,14 @@ int ModalLayer::execute(ModalDialog& content, const QRect& anchorRect) content.hide(); delete host; - m_stack.erase(std::remove(m_stack.begin(), m_stack.end(), &content), m_stack.end()); + for (std::size_t i = m_stack.size(); i > 0; --i) + { + if (m_stack[i - 1].content == &content) + { + m_stack.erase(m_stack.begin() + static_cast(i - 1)); + break; + } + } updateVisibility(); return content.result(); } @@ -104,9 +143,8 @@ void ModalLayer::place(QScrollArea& host, ModalDialog& content, const QSize hostSize = content.size().boundedTo(size()); host.resize(hostSize); - const QRect anchor = anchorRect.isNull() ? rect() : anchorRect; - QPoint topLeft(anchor.center().x() - hostSize.width() / 2, - anchor.center().y() - hostSize.height() / 2); + QPoint topLeft(anchorRect.center().x() - hostSize.width() / 2, + anchorRect.center().y() - hostSize.height() / 2); // Pushed back inside the layer, which is the game window (REQ-UI-PANEL-MODAL). The // far edge is clamped first and the near edge second, which is what aligns a modal diff --git a/src/ui/ModalLayer.h b/src/ui/ModalLayer.h index 1c05edd..f9c7e85 100644 --- a/src/ui/ModalLayer.h +++ b/src/ui/ModalLayer.h @@ -24,12 +24,18 @@ class ModalLayer : public QWidget Q_OBJECT public: + // The layer a widget is shown on, found by walking up its parents, or nullptr when + // it is not on one. How a widget deep inside a modal reaches the layer to open a + // second modal on it, without every widget between them having to carry a pointer. + static ModalLayer* findFor(const QWidget& widget); + ModalLayer(const QColor& dimColor, QWidget* parent); // Shows content on this layer and runs it until it accepts or rejects, returning its // QDialog result code. anchorRect (in this layer's coordinates, which are the main - // window's) is what the content is centered on; a null rect centers it on the layer - // itself (REQ-UI-PANEL-MODAL). + // window's) is what the content is centered on. A null rect centers it on the modal + // it was opened from, and on the layer itself when it is the first one open + // (REQ-UI-PANEL-MODAL). int execute(ModalDialog& content, const QRect& anchorRect = QRect()); // Whether a modal is open. The main window asks before handing focus back to the @@ -48,15 +54,24 @@ protected: void paintEvent(QPaintEvent* event) override; private: + // A modal and the scroll area it is shown in. The host is what the layer places and + // what the player sees the edges of, so it is also the rectangle "outside the modal" + // is measured against. + struct HostedModal + { + ModalDialog* content; + QScrollArea* host; + }; + // Sizes content to what it asks for, capped at the layer, and centers it on // anchorRect (REQ-UI-PANEL-MODAL). void place(QScrollArea& host, ModalDialog& content, const QRect& anchorRect) const; void updateVisibility(); - QColor m_dimColor; - std::vector m_stack; // bottom-most first; back() is the open one - int m_holdCount = 0; + QColor m_dimColor; + std::vector m_stack; // bottom-most first; back() is the open one + int m_holdCount = 0; }; // RAII guard for addHold()/removeHold(). diff --git a/src/ui/NameInputDialog.cpp b/src/ui/NameInputDialog.cpp new file mode 100644 index 0000000..cccce27 --- /dev/null +++ b/src/ui/NameInputDialog.cpp @@ -0,0 +1,56 @@ +#include "NameInputDialog.h" + +#include +#include +#include +#include +#include + +namespace +{ + const int kSpacingPx = 8; + const int kMinimumWidthPx = 280; +} + +NameInputDialog::NameInputDialog(const QString& title, const QString& prompt, + QWidget* parent) + : ModalDialog(parent) + , m_nameEdit(nullptr) +{ + QVBoxLayout* mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(kSpacingPx, kSpacingPx, kSpacingPx, kSpacingPx); + mainLayout->setSpacing(kSpacingPx); + + QLabel* promptLabel = new QLabel(prompt, this); + mainLayout->addWidget(promptLabel); + + m_nameEdit = new QLineEdit(this); + m_nameEdit->setMinimumWidth(kMinimumWidthPx); + mainLayout->addWidget(m_nameEdit); + + QHBoxLayout* buttonLayout = new QHBoxLayout(); + buttonLayout->setSpacing(kSpacingPx); + buttonLayout->addStretch(); + + QPushButton* confirmButton = new QPushButton(tr("Confirm"), this); + QPushButton* cancelButton = new QPushButton(tr("Cancel"), this); + buttonLayout->addWidget(confirmButton); + buttonLayout->addWidget(cancelButton); + mainLayout->addLayout(buttonLayout); + + connect(confirmButton, &QPushButton::clicked, this, &QDialog::accept); + connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject); + // Enter confirms from the line edit, as it did in the prompt this replaces. + connect(m_nameEdit, &QLineEdit::returnPressed, this, &QDialog::accept); + + addHeader(mainLayout, title, false); + + // The line edit is what the player came here to use, and it takes Q as a character + // because it holds the focus (REQ-UI-DIALOG-DISMISS). + m_nameEdit->setFocus(); +} + +QString NameInputDialog::getName() const +{ + return m_nameEdit->text().trimmed(); +} diff --git a/src/ui/NameInputDialog.h b/src/ui/NameInputDialog.h new file mode 100644 index 0000000..77f1ba2 --- /dev/null +++ b/src/ui/NameInputDialog.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +#include "ModalDialog.h" + +class QLineEdit; + +// The game's own name prompt (REQ-UI-MODAL-CHROME): a title, a prompt, a line edit, and +// Confirm and Cancel. It stands in for QInputDialog at the two places a blueprint is +// named -- the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE) and the Create Blueprint +// dialog of the layout configuration dialog (REQ-MOD-UI-BLUEPRINT-CREATE). +// +// Neither Q nor a click outside dismisses it (ModalDialog::isDismissible stays false): +// Q is a character the player may be typing, and a half-typed name is work in progress +// that no stray gesture may discard (REQ-UI-DIALOG-DISMISS). Escape and Cancel remain. +// The caller decides what an empty name means; the dialog reports it trimmed. +class NameInputDialog : public ModalDialog +{ + Q_OBJECT + +public: + NameInputDialog(const QString& title, const QString& prompt, + QWidget* parent = nullptr); + + QString getName() const; + +private: + QLineEdit* m_nameEdit; +}; diff --git a/src/ui/ShipLayoutDialog.cpp b/src/ui/ShipLayoutDialog.cpp index fdae58d..0a99960 100644 --- a/src/ui/ShipLayoutDialog.cpp +++ b/src/ui/ShipLayoutDialog.cpp @@ -5,6 +5,8 @@ #include #include "DisplayName.h" +#include "ModalLayer.h" +#include "NameInputDialog.h" #include "OptionButton.h" #include "ProductionRules.h" #include "RecipeLineRow.h" @@ -12,7 +14,6 @@ #include #include -#include #include #include #include @@ -336,14 +337,18 @@ public: layout->addWidget(m_scrollArea, 1); connect(createBtn, &QPushButton::clicked, this, [this]() { - bool ok = false; - const QString name = QInputDialog::getText( - this, tr("Create Blueprint"), tr("Blueprint name:"), - QLineEdit::Normal, QString(), &ok); - if (!ok || name.trimmed().isEmpty()) { return; } + // Opened on the same layer as the dialog this panel sits in, so it stacks + // over it on the one dim (REQ-MOD-UI-BLUEPRINT-CREATE, REQ-UI-MODAL-DIM). + ModalLayer* layer = ModalLayer::findFor(*this); + if (layer == nullptr) { return; } + + NameInputDialog nameDialog(tr("Create Blueprint"), tr("Blueprint name:")); + if (layer->execute(nameDialog) != QDialog::Accepted) { return; } + const QString name = nameDialog.getName(); + if (name.isEmpty()) { return; } ShipLayoutBlueprint bp; - bp.name = name.trimmed(); + bp.name = name; bp.shipType = m_shipType; bp.modules = m_getModules(); m_allBlueprints.push_back(std::move(bp));