334 lines
13 KiB
C++
334 lines
13 KiB
C++
#include "BlueprintSelectionDialog.h"
|
|
|
|
#include <cstddef>
|
|
#include <vector>
|
|
|
|
#include <QChar>
|
|
#include <QColor>
|
|
#include <QFont>
|
|
#include <QFontMetrics>
|
|
#include <QFrame>
|
|
#include <QGridLayout>
|
|
#include <QGuiApplication>
|
|
#include <QHBoxLayout>
|
|
#include <QIcon>
|
|
#include <QLabel>
|
|
#include <QLayoutItem>
|
|
#include <QPainter>
|
|
#include <QPalette>
|
|
#include <QPixmap>
|
|
#include <QPoint>
|
|
#include <QPushButton>
|
|
#include <QRect>
|
|
#include <QScrollArea>
|
|
#include <QSize>
|
|
#include <QString>
|
|
#include <QStyle>
|
|
#include <QVBoxLayout>
|
|
|
|
#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<int>(faceSize.width() * dpr),
|
|
static_cast<int>(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<int>(
|
|
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<int> 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<Blueprint>& 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<int>(blueprints.size()); ++i)
|
|
{
|
|
const Blueprint& blueprint = blueprints[static_cast<std::size_t>(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();
|
|
}
|