host the modals in the window instead of in windows of their own

Every dialog was an OS window with a dim widget behind it in the game
window. That is why a click beside one could not reach it: Qt drops mouse
events for a window a modal blocks, so the dim -- a child of the blocked
window -- never saw the press.

ModalLayer is that dim grown up: still a child of the main window covering
its rect, but it now hosts the open modal, places it, and runs its event
loop, so it is the widget the clicks beside a modal land on. It keeps a
stack, paints one dim however many modals are open, and a hold keeps it up
while one modal hands over to the next. ModalDimOverlay and ModalDimScope
are gone; ModalLayerHold replaces the scope at the sites that still open a
system message box.

ModalDialog is what a dialog inherits in place of the window it lost: the
panel background, the drawn header with its optional close button, and the
dismissal gestures. isDismissible() governs Q and, next, the click outside
-- one predicate because both gestures reach the same dialogs. Its default
refuses, so a dialog opts in. requestDismiss() is what a gesture asks for,
and ShipLayoutDialog overrides it with the one-step ladder its Q handler
used to spell out, which is now reached by both. DialogDismiss.h folded into
the base.

The four dialogs the player meets keep their contents unchanged and lose
their title bars: the recipe/schematic selection, blueprint selection, ship
layout, and schematic choice dialogs. Placement moved with them --
placeOnSelectionPanel became getSelectionPanelAnchor, and the layer centers
on that rectangle in window coordinates, with no global mapping and no
frame height to guess at.

Two things followed from dropping OS modality: the focus guard that hands
focus back to the game world now also asks the layer whether a modal is
open, and closeEvent refuses to close the window while one is, since its
nested loop runs over widgets the window owns.

The escape menu, the game-over and win screens, and the two name dialogs are
still system dialogs; they are dimmed by a layer hold until they are
converted next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
This commit is contained in:
2026-08-18 12:07:25 +02:00
parent 7327343b2a
commit f03003194f
19 changed files with 507 additions and 312 deletions

View File

@@ -28,7 +28,6 @@
#include "Blueprint.h"
#include "BlueprintLibrary.h"
#include "DialogDismiss.h"
#include "IconCaption.h"
#include "ItemIconCache.h"
@@ -150,54 +149,28 @@ namespace
BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library,
ItemIconCache* itemIcons,
QWidget* parent)
: QDialog(parent)
: ModalDialog(parent)
, m_library(library)
, m_itemIcons(itemIcons)
{
setWindowTitle(tr("Blueprints"));
setModal(true);
// Frameless: the dialog draws its own header row, so an OS title bar would only
// repeat it (REQ-UI-BLUEPRINT-DIALOG). Square corners rather than rounded ones --
// rounding a top-level window needs a translucent background, which is unreliable
// on Windows. The border matches the build bar and the selection panel.
setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint);
setAttribute(Qt::WA_StyledBackground, true);
setStyleSheet(QStringLiteral(
"BlueprintSelectionDialog { background-color: palette(window);"
" border: 1px solid palette(mid); }"));
QVBoxLayout* mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(kSpacingPx, kSpacingPx, kSpacingPx, kSpacingPx);
mainLayout->setSpacing(kSpacingPx);
QHBoxLayout* headerLayout = new QHBoxLayout();
headerLayout->setSpacing(kSpacingPx);
QLabel* titleLabel = new QLabel(tr("Blueprints"), this);
QFont headerFont = titleLabel->font();
headerFont.setBold(true);
titleLabel->setFont(headerFont);
headerLayout->addWidget(titleLabel);
QHBoxLayout* headerLayout = addHeader(mainLayout, tr("Blueprints"), true);
// Dimmed and bold, the treatment the build button hotkey badges use, so the
// shortcut reads as a reminder rather than a second title (REQ-UI-BUILD-COST).
// Inserted right after the title, left of the stretch the header ends with.
QLabel* hotkeyBadge = new QLabel(tr("Ctrl+V"), this);
hotkeyBadge->setFont(headerFont);
QFont badgeFont = hotkeyBadge->font();
badgeFont.setBold(true);
hotkeyBadge->setFont(badgeFont);
QPalette badgePalette = hotkeyBadge->palette();
badgePalette.setColor(hotkeyBadge->foregroundRole(),
palette().color(QPalette::Disabled, QPalette::WindowText));
hotkeyBadge->setPalette(badgePalette);
headerLayout->addWidget(hotkeyBadge);
headerLayout->addStretch();
QPushButton* closeButton = new QPushButton(QString(kCrossGlyph), this);
closeButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx);
closeButton->setToolTip(tr("Close"));
connect(closeButton, &QPushButton::clicked, this, &QDialog::reject);
headerLayout->addWidget(closeButton);
mainLayout->addLayout(headerLayout);
headerLayout->insertWidget(1, hotkeyBadge);
QScrollArea* scrollArea = new QScrollArea(this);
scrollArea->setWidgetResizable(true);
@@ -223,13 +196,11 @@ BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library,
setFixedSize(gridWidth + style()->pixelMetric(QStyle::PM_ScrollBarExtent)
+ 2 * kSpacingPx,
gridHeight + kSmallButtonSizePx + 3 * kSpacingPx);
}
// A frameless dialog does not get Qt's automatic centering on its parent.
if (parent)
{
const QRect parentRect = parent->window()->geometry();
move(parentRect.center() - QPoint(width() / 2, height() / 2));
}
bool BlueprintSelectionDialog::isDismissible() const
{
return true;
}
std::optional<int> BlueprintSelectionDialog::getChosenIndex() const
@@ -237,19 +208,6 @@ std::optional<int> 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