#include "BlueprintSelectionDialog.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "Blueprint.h" #include "BlueprintLibrary.h" #include "IconCaption.h" #include "ItemIconCache.h" namespace { // Fixed at two, per REQ-UI-BLUEPRINT-DIALOG; the grid scrolls rather than reflowing. const int kGridColumnCount = 2; // Card geometry. Every card is the same size so the grid stays even // (REQ-UI-BLUEPRINT-CARD); the width is chosen to fit a name and a short contents // line, and the height follows from three text rows. const int kCardWidthPx = 220; const int kCardPaddingPx = 10; const int kCardRowGapPx = 4; // Side length of the per-card delete icon and of the dialog's close button. const int kSmallButtonSizePx = 22; // Spacing between cards, and the dialog's own content margin. const int kSpacingPx = 8; // Card rows visible before the grid scrolls. The half row is deliberate: a cut-off // card reads as "there is more below" at a glance. const double kVisibleRowCount = 2.5; // U+00D7 MULTIPLICATION SIGN, the close and delete glyph. Written as a code point // because the sources are not guaranteed to be read as UTF-8 by every compiler. const QChar kCrossGlyph(0x00D7); // Inner size of a card's composed face: the name row, the contents row, and the // cost row, plus the gap above the cost. QSize getCardFaceSize(const QFont& font) { const int rowHeight = QFontMetrics(font).height(); return QSize(kCardWidthPx - 2 * kCardPaddingPx, rowHeight * 3 + kCardRowGapPx); } QSize getCardSize(const QFont& font) { const QSize faceSize = getCardFaceSize(font); return QSize(faceSize.width() + 2 * kCardPaddingPx, faceSize.height() + 2 * kCardPaddingPx); } // One card face: the blueprint name, its contents line below in a dimmed color, // and the cost with its block icon at the bottom (REQ-UI-BLUEPRINT-CARD). The three // are composed into a single pixmap because a QPushButton holds only one icon -- // the same device the build button faces use. QPixmap composeCardFace(const QString& name, const QString& contents, const QPixmap& cost, const QFont& font, const QColor& textColor, const QColor& dimColor, const QSize& faceSize) { const QFontMetrics metrics(font); const int rowHeight = metrics.height(); const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0; QPixmap face(static_cast(faceSize.width() * dpr), static_cast(faceSize.height() * dpr)); face.setDevicePixelRatio(dpr); face.fill(Qt::transparent); QPainter painter(&face); painter.setRenderHint(QPainter::Antialiasing, true); painter.setRenderHint(QPainter::SmoothPixmapTransform, true); painter.setFont(font); // Both text rows are elided rather than wrapped or shrunk, so a long name or a // many-typed blueprint cannot change a card's height (REQ-UI-BLUEPRINT-CARD). painter.setPen(textColor); painter.drawText(QRect(0, 0, faceSize.width(), rowHeight), Qt::AlignLeft | Qt::AlignVCenter, metrics.elidedText(name, Qt::ElideRight, faceSize.width())); painter.setPen(dimColor); painter.drawText(QRect(0, rowHeight, faceSize.width(), rowHeight), Qt::AlignLeft | Qt::AlignVCenter, metrics.elidedText(contents, Qt::ElideRight, faceSize.width())); const QSize costSize = getLogicalSize(cost); painter.drawPixmap(0, faceSize.height() - costSize.height(), cost); return face; } // A composed card face and the size to show it at; the button needs both, and only // the composer knows the size it arrived at. struct CardFace { QIcon icon; QSize size; }; // The two-mode face of one card. The modes differ only in the text color, so an // unaffordable card greys itself when Qt swaps the pixmap on the disabled button // (REQ-UI-BLUEPRINT-CARD, consistent with REQ-UI-BUILD-DISABLED). CardFace buildCardFace(const QString& name, const QString& contents, int cost, const QPixmap& blockIcon, const QFont& font, const QPalette& palette) { const QSize faceSize = getCardFaceSize(font); const QColor dimColor = palette.color(QPalette::Disabled, QPalette::ButtonText); CardFace result; for (QIcon::Mode mode : { QIcon::Normal, QIcon::Disabled }) { const QColor textColor = palette.color( (mode == QIcon::Normal) ? QPalette::Active : QPalette::Disabled, QPalette::ButtonText); // The cost reads exactly like the header stock and the build button costs: // the number, then the block icon in place of a trailing "Blocks" // (REQ-UI-BLOCKS-ICON). A null icon leaves the bare number. const QPixmap costPixmap = renderCaptionWithIcon(QString::number(cost), blockIcon, font, textColor); const QPixmap face = composeCardFace(name, contents, costPixmap, font, textColor, dimColor, faceSize); result.icon.addPixmap(face, mode); result.size = result.size.expandedTo(getLogicalSize(face)); } return result; } } BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library, ItemIconCache* itemIcons, QWidget* parent) : QDialog(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 side 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); // 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). QLabel* hotkeyBadge = new QLabel(tr("Ctrl+V"), this); hotkeyBadge->setFont(headerFont); 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); QScrollArea* scrollArea = new QScrollArea(this); scrollArea->setWidgetResizable(true); scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); scrollArea->setFrameShape(QFrame::NoFrame); m_gridContainer = new QWidget(scrollArea); m_grid = new QGridLayout(m_gridContainer); m_grid->setContentsMargins(0, 0, 0, 0); m_grid->setSpacing(kSpacingPx); m_grid->setAlignment(Qt::AlignTop | Qt::AlignLeft); scrollArea->setWidget(m_gridContainer); mainLayout->addWidget(scrollArea, 1); rebuildGrid(); // Fixed size (REQ-UI-BLUEPRINT-DIALOG), sized to show kVisibleRowCount card rows. const QSize cardSize = getCardSize(font()); const int gridWidth = kGridColumnCount * cardSize.width() + (kGridColumnCount - 1) * kSpacingPx; const int gridHeight = static_cast( cardSize.height() * kVisibleRowCount + kSpacingPx * (kVisibleRowCount - 1.0)); 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)); } } std::optional BlueprintSelectionDialog::getChosenIndex() const { return m_chosenIndex; } void BlueprintSelectionDialog::rebuildGrid() { // deleteLater, not delete: this runs from a delete button's own clicked signal, and // destroying that button inside its emission would leave Qt holding a dangling // sender. hide() makes the removal immediate all the same. while (QLayoutItem* item = m_grid->takeAt(0)) { if (item->widget()) { item->widget()->hide(); item->widget()->deleteLater(); } delete item; } const std::vector& blueprints = m_library->getBlueprints(); if (blueprints.empty()) { QLabel* emptyLabel = new QLabel( tr("No blueprints yet.\n\nSelect one or more buildings and press Ctrl+C " "to save them as a blueprint."), m_gridContainer); emptyLabel->setAlignment(Qt::AlignCenter); emptyLabel->setWordWrap(true); m_grid->addWidget(emptyLabel, 0, 0, 1, kGridColumnCount); return; } // Block icon shown to the right of each cost (REQ-UI-BLUEPRINT-CARD); null when no // building_block icon exists, in which case the cost is the bare number. const QPixmap blockIcon = m_itemIcons->hasIcon(kBlockItemId) ? m_itemIcons->getPixmap(kBlockItemId, QFontMetrics(font()).height()) : QPixmap(); const QSize cardSize = getCardSize(font()); for (int i = 0; i < static_cast(blueprints.size()); ++i) { const Blueprint& blueprint = blueprints[static_cast(i)]; const CardFace face = buildCardFace( blueprint.name, m_library->getContentsSummary(i), m_library->getCost(i), blockIcon, font(), palette()); QWidget* card = new QWidget(m_gridContainer); card->setFixedSize(cardSize); QVBoxLayout* cardLayout = new QVBoxLayout(card); cardLayout->setContentsMargins(0, 0, 0, 0); QPushButton* body = new QPushButton(card); body->setIcon(face.icon); body->setIconSize(face.size); // An unaffordable card is greyed and does not respond, but its delete icon // stays live (REQ-UI-BLUEPRINT-CARD). body->setEnabled(m_library->getCanAfford(i)); cardLayout->addWidget(body); // A sibling of the body rather than one of its children, and outside the // layout: Qt disables a widget's children along with it, and the delete icon // must stay enabled on an unaffordable card (REQ-UI-BLUEPRINT-DELETE). QPushButton* deleteButton = new QPushButton(QString(kCrossGlyph), card); deleteButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx); deleteButton->setToolTip(tr("Delete blueprint")); deleteButton->move(cardSize.width() - kSmallButtonSizePx - kCardPaddingPx / 2, cardSize.height() - kSmallButtonSizePx - kCardPaddingPx / 2); deleteButton->raise(); m_grid->addWidget(card, i / kGridColumnCount, i % kGridColumnCount); const int index = i; connect(body, &QPushButton::clicked, this, [this, index]() { onCardClicked(index); }); connect(deleteButton, &QPushButton::clicked, this, [this, index]() { onDeleteClicked(index); }); } } void BlueprintSelectionDialog::onCardClicked(int index) { // Placement mode is entered by the caller once the dialog has closed // (REQ-UI-BLUEPRINT-CARD). m_chosenIndex = index; accept(); } void BlueprintSelectionDialog::onDeleteClicked(int index) { // No confirmation prompt, and the dialog stays open (REQ-UI-BLUEPRINT-DELETE). m_library->remove(index); rebuildGrid(); }