Files
dota_factory/src/ui/BlueprintSelectionDialog.cpp

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();
}