#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" #include "ItemTooltip.h" #include "TooltipTrigger.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, the size to show it at, and where in it the cost ended up: // the button needs the first two, and only the composer knows the third, which is // what the cost's tooltip is hung on (REQ-UI-ITEM-VALUE-TOOLTIP). struct CardFace { QIcon icon; QSize size; QRect costRect; }; // 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)); // Where composeCardFace put it: bottom left, its own width. Both modes // compose the same text, so both arrive at the same rect. const QSize costSize = getLogicalSize(costPixmap); result.costRect = QRect(0, faceSize.height() - costSize.height(), costSize.width(), costSize.height()); } return result; } } BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library, const ItemTooltipContext& context, QWidget* parent) : ModalDialog(parent) , m_library(library) , m_context(context) , m_itemIcons(context.itemIcons) { QVBoxLayout* mainLayout = new QVBoxLayout(this); mainLayout->setContentsMargins(kSpacingPx, kSpacingPx, kSpacingPx, kSpacingPx); mainLayout->setSpacing(kSpacingPx); 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); 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->insertWidget(1, hotkeyBadge); 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); } bool BlueprintSelectionDialog::isDismissible() const { return true; } 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->getInlineIcon(kBlockItemId, font()); 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); // The cost is painted into the face, so what explains the item it names is a // child of its own laid over that band -- placed by hand like the delete icon // below (REQ-UI-ITEM-VALUE-TOOLTIP). The face is centered in the button, which // is what the offset accounts for. A child of the body rather than a sibling, so // that the press it does not handle still reaches the button and picks the // blueprint; hover only, for the same reason (REQ-UI-TOOLTIP-TRIGGER). QWidget* costArea = new QWidget(body); const QPoint faceOrigin((cardSize.width() - face.size.width()) / 2, (cardSize.height() - face.size.height()) / 2); costArea->setGeometry(QRect(faceOrigin + face.costRect.topLeft(), face.costRect.size())); ItemTooltip::attachTo(*costArea, m_context, kBlockItemId, TooltipTrigger::Trigger::HoverOnly); // 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); // Hover only: the click deletes the blueprint (REQ-UI-TOOLTIP-TRIGGER). TooltipTrigger::attachText(*deleteButton, tr("Delete blueprint"), TooltipTrigger::Trigger::HoverOnly); 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(); }