Every display naming an item now shows that item's production tooltip, not only the selection panel's chips: the header block stock, the total cost of a building multi-selection, the remaining scrap of a debris selection, the icons of every recipe summary, and a blueprint card's cost. ItemTooltip moves out of the selection panel and takes an ItemTooltipContext of its own -- an item is named all over the UI, and the panel's context carries visuals and a debug flag no tooltip reads. A recipe line wraps each icon with its amount so the pair can be pointed at as one statement, and attaches tooltips only where asked: a module button and the item tooltip itself draw the same line and stay silent. Hover only wherever the display sits on something the player clicks, whose click is not free to explain. Note that a recipe line on an option button can no longer be transparent to the mouse -- Qt never looks inside a transparent widget for the cursor -- so it relies on press propagation to keep picking the option. The header block stock loses world.building_blocks_tooltip, showing what every other block icon shows instead. The Expand button keeps no tooltip: its cost is painted into its face with nowhere to hang one, and the button is due to be removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
329 lines
14 KiB
C++
329 lines
14 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"
|
|
#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<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, 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<int>(
|
|
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<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->getInlineIcon(kBlockItemId, font());
|
|
|
|
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);
|
|
|
|
// 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();
|
|
}
|