One tooltip mechanism for the whole UI: a Tooltip popup plus a TooltipTrigger event filter, replacing every setToolTip call. Qt's own tooltip cannot do what REQ-UI-TOOLTIP-TRIGGER and REQ-UI-TOOLTIP-DISMISS ask for -- it times out, hides on the first mouse move, cannot be hovered and cannot be brought up by a click. A tooltip now appears the moment an element with no click action of its own is clicked, and it is placed with its top-left corner on the pointer, so the pointer can move onto the tooltip and hold it open. The pointer is polled while a tooltip is up rather than tracked through enter and leave events, whose order depends on which widget the pointer crosses first. The item chip's children become transparent to the mouse. They were taking the chip's enter and leave events, so its tooltip only ever appeared when the pointer rested on the chip's padding. The modal header's close button loses its "Close" tooltip: a close button in a header says that by being one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
417 lines
17 KiB
C++
417 lines
17 KiB
C++
#include "BuildButtonBar.h"
|
|
|
|
#include <string>
|
|
|
|
#include <QColor>
|
|
#include <QFont>
|
|
#include <QFontMetrics>
|
|
#include <QGuiApplication>
|
|
#include <QHBoxLayout>
|
|
#include <QIcon>
|
|
#include <QPainter>
|
|
#include <QPalette>
|
|
#include <QPixmap>
|
|
#include <QPushButton>
|
|
#include <QRect>
|
|
#include <QSignalMapper>
|
|
#include <QSize>
|
|
#include <QString>
|
|
|
|
#include "BuildingIconCache.h"
|
|
#include "BuildingType.h"
|
|
#include "BuildingTypeSelectedEvent.h"
|
|
#include "DeconstructModeToggleRequestedEvent.h"
|
|
#include "DisplayName.h"
|
|
#include "EventManager.h"
|
|
#include "ExitBuilderModeRequestedEvent.h"
|
|
#include "FloatingLayoutInvalidatedEvent.h"
|
|
#include "IconCaption.h"
|
|
#include "InputMapper.h"
|
|
#include "ItemIconCache.h"
|
|
#include "Simulation.h"
|
|
#include "TooltipTrigger.h"
|
|
|
|
namespace
|
|
{
|
|
// Size the SVG chips are drawn at on a button face, in device-independent pixels.
|
|
const QSize kIconSize(32, 32);
|
|
|
|
// Minimum width of a button face. The building-type buttons all come out at this
|
|
// width, so the row is uniform; a face whose caption is wider than this — the
|
|
// Deconstruct button's name — grows to fit rather than clipping
|
|
// (REQ-UI-DECONSTRUCT-BUTTON).
|
|
const int kFaceMinWidthPx = 48;
|
|
|
|
// Gap between the last building button and the Deconstruct button, which toggles
|
|
// a mode rather than selecting a building type (REQ-UI-DECONSTRUCT-BUTTON).
|
|
const int kDeconstructGapPx = 16;
|
|
|
|
// Distance from the bar to the bottom edge of the game world view
|
|
// (REQ-UI-BUILD-BAR).
|
|
const int kBottomMarginPx = 8;
|
|
|
|
// Gap between the chip icon and the cost line on a button face.
|
|
const int kFaceGapPx = 2;
|
|
|
|
// Normal and grey-background chip pixmaps for one icon name. Empty pixmaps if the
|
|
// file cannot be read; the caller then falls back to a name caption
|
|
// (REQ-UI-BUILD-ICON).
|
|
struct ChipPixmaps { QPixmap normal; QPixmap grey; };
|
|
ChipPixmaps loadChipPixmaps(BuildingIconCache& icons, const std::string& iconName)
|
|
{
|
|
ChipPixmaps result;
|
|
result.normal = icons.getChip(iconName, kIconSize.width());
|
|
result.grey = icons.getGreyChip(iconName, kIconSize.width());
|
|
return result;
|
|
}
|
|
|
|
// One button face: the hotkey badge in the top-left corner, the chip icon centered
|
|
// below it, and the caption — the cost, or the Deconstruct name — centered at the
|
|
// bottom (REQ-UI-BUILD-COST). The three are composed into a single pixmap because
|
|
// a QPushButton holds only one icon.
|
|
QPixmap composeButtonFace(const QString& hotkeyLabel, const QPixmap& chip,
|
|
const QPixmap& caption, const QFont& badgeFont,
|
|
const QColor& badgeColor)
|
|
{
|
|
const QSize chipSize = getLogicalSize(chip);
|
|
const QSize captionSize = getLogicalSize(caption);
|
|
// The badge row is kept even for a building type without a hotkey, so buttons
|
|
// stay the same height and the row reads as one strip (REQ-UI-BUILD-COST).
|
|
const int badgeHeight = QFontMetrics(badgeFont).height();
|
|
const int width = qMax(kFaceMinWidthPx, qMax(chipSize.width(), captionSize.width()));
|
|
const int height = badgeHeight + chipSize.height() + kFaceGapPx + captionSize.height();
|
|
|
|
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
|
|
QPixmap face(static_cast<int>(width * dpr), static_cast<int>(height * dpr));
|
|
face.setDevicePixelRatio(dpr);
|
|
face.fill(Qt::transparent);
|
|
|
|
QPainter painter(&face);
|
|
painter.setRenderHint(QPainter::Antialiasing, true);
|
|
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
|
if (!hotkeyLabel.isEmpty())
|
|
{
|
|
painter.setFont(badgeFont);
|
|
painter.setPen(badgeColor);
|
|
painter.drawText(QRect(0, 0, width, badgeHeight),
|
|
Qt::AlignLeft | Qt::AlignVCenter, hotkeyLabel);
|
|
}
|
|
painter.drawPixmap((width - chipSize.width()) / 2, badgeHeight, chip);
|
|
painter.drawPixmap((width - captionSize.width()) / 2,
|
|
badgeHeight + chipSize.height() + kFaceGapPx, caption);
|
|
return face;
|
|
}
|
|
|
|
// A composed button face and the size to show it at; the button needs both, and
|
|
// only the composer knows the size it arrived at.
|
|
struct ButtonFace { QIcon icon; QSize size; };
|
|
|
|
// The two-mode face of one build button. The modes differ only in the chip variant
|
|
// and the text color, so a disabled (unaffordable) button greys itself when Qt
|
|
// swaps the pixmap, with no extra work in updateAffordability()
|
|
// (REQ-UI-BUILD-DISABLED).
|
|
ButtonFace buildButtonFace(const ChipPixmaps& chip, const QString& hotkeyLabel,
|
|
const QString& name, const QString& captionText,
|
|
const QPixmap& blockIcon, const QFont& font,
|
|
const QPalette& palette)
|
|
{
|
|
// Full button size and bold: at a smaller size the badge was hard to read and
|
|
// its arrow glyph illegible. It stays dimmed in both modes instead, so it
|
|
// reads as a reminder without competing with the cost.
|
|
QFont badgeFont = font;
|
|
badgeFont.setBold(true);
|
|
const QColor badgeColor = palette.color(QPalette::Disabled, QPalette::ButtonText);
|
|
|
|
ButtonFace result;
|
|
for (QIcon::Mode mode : { QIcon::Normal, QIcon::Disabled })
|
|
{
|
|
const bool enabled = (mode == QIcon::Normal);
|
|
const QColor textColor = palette.color(
|
|
enabled ? QPalette::Active : QPalette::Disabled, QPalette::ButtonText);
|
|
const QPixmap& chipPixmap = enabled ? chip.normal : chip.grey;
|
|
// A missing "<id>.svg" puts the building name where the chip would be, so
|
|
// the button stays identifiable in an icon-only bar (REQ-UI-BUILD-ICON).
|
|
const QPixmap middle = chipPixmap.isNull()
|
|
? renderCaptionWithIcon(name, QPixmap(), font, textColor)
|
|
: chipPixmap;
|
|
const QPixmap caption =
|
|
renderCaptionWithIcon(captionText, blockIcon, font, textColor);
|
|
const QPixmap face =
|
|
composeButtonFace(hotkeyLabel, middle, caption, badgeFont, badgeColor);
|
|
result.icon.addPixmap(face, mode);
|
|
// Both modes compose to the same size; keeping the larger is only a guard
|
|
// against a fallback name caption widening one of them.
|
|
result.size = result.size.expandedTo(getLogicalSize(face));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
}
|
|
|
|
|
|
BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
|
|
BuildingIconCache* buildingIcons,
|
|
ItemIconCache* itemIcons, QWidget* parent)
|
|
: QWidget(parent)
|
|
, m_sim(sim)
|
|
, m_config(config)
|
|
, m_buildingIcons(buildingIcons)
|
|
, m_itemIcons(itemIcons)
|
|
{
|
|
// The bar floats over the rendered world rather than sitting in a panel, so it
|
|
// brings its own opaque background to stay legible over any world content
|
|
// (REQ-UI-BUILD-BAR). Palette colors keep it consistent with the buttons it holds
|
|
// and with the selection panel; this is widget chrome, not world rendering, so it is
|
|
// deliberately not a visuals.toml color.
|
|
setAttribute(Qt::WA_StyledBackground, true);
|
|
setStyleSheet(QStringLiteral(
|
|
"BuildButtonBar { background-color: palette(window);"
|
|
" border: 1px solid palette(mid); border-radius: 4px; }"));
|
|
|
|
QHBoxLayout* layout = new QHBoxLayout(this);
|
|
layout->setSpacing(4);
|
|
layout->setContentsMargins(6, 4, 6, 4);
|
|
|
|
QSignalMapper* mapper = new QSignalMapper(this);
|
|
|
|
// Block icon shown to the right of each button's cost (REQ-UI-BUILD-COST); null
|
|
// when no building_block icon exists, in which case the cost is the bare number.
|
|
const QPixmap blockIcon = m_itemIcons->getInlineIcon(kBlockItemId, font());
|
|
|
|
for (const BuildingDef& def : config->buildings.buildings)
|
|
{
|
|
if (!def.playerPlaceable)
|
|
{
|
|
continue;
|
|
}
|
|
// Tunnel Entry and Tunnel Exit share a single "Tunnel" button; the exit is
|
|
// reached through the unified tunnel build mode, not its own button
|
|
// (REQ-BLD-TUNNEL-MODE, REQ-UI-BUILD-BAR). Both stay player-placeable so
|
|
// blueprints and cost totals still account for exits.
|
|
if (def.type == BuildingType::TunnelExit)
|
|
{
|
|
continue;
|
|
}
|
|
m_types.push_back(def.type);
|
|
m_costs[def.type] = def.cost;
|
|
|
|
const QString name = (def.type == BuildingType::TunnelEntry)
|
|
? tr("Tunnel")
|
|
: QString::fromStdString(toDisplayName(def.id));
|
|
|
|
// Icon file name matches the building id (REQ-UI-BUILD-ICON); Tunnel Entry's
|
|
// "tunnel_entry.svg" serves the shared Tunnel button.
|
|
const ButtonFace face = buildButtonFace(
|
|
loadChipPixmaps(*m_buildingIcons, def.id),
|
|
InputMapper::getBuildHotkeyLabel(def.type), name,
|
|
QString::number(def.cost), blockIcon, font(), palette());
|
|
|
|
QPushButton* btn = new QPushButton(this);
|
|
btn->setIcon(face.icon);
|
|
btn->setIconSize(face.size);
|
|
btn->setCheckable(true);
|
|
// The button face carries no name (REQ-UI-BUILD-COST), so the tooltip always
|
|
// leads with it and adds the config description when there is one
|
|
// (REQ-UI-BUILD-TOOLTIP). Hover only: the click enters builder mode
|
|
// (REQ-UI-TOOLTIP-TRIGGER).
|
|
TooltipTrigger::attachText(*btn, def.tooltip
|
|
? QStringLiteral("%1\n%2").arg(name, QString::fromStdString(*def.tooltip))
|
|
: name,
|
|
TooltipTrigger::Trigger::HoverOnly);
|
|
layout->addWidget(btn);
|
|
|
|
const int idx = static_cast<int>(m_buttons.size());
|
|
m_buttons.push_back(btn);
|
|
mapper->setMapping(btn, idx);
|
|
connect(btn, &QPushButton::clicked, mapper, qOverload<>(&QSignalMapper::map));
|
|
}
|
|
connect(mapper, qOverload<int>(&QSignalMapper::mapped), this, &BuildButtonBar::onBuildButton);
|
|
|
|
// Set apart from the building-type buttons by a gap, because it toggles a mode
|
|
// rather than selecting a building type (REQ-UI-DECONSTRUCT-BUTTON). A fixed
|
|
// spacer rather than a stretch: the bar is sized to its contents, so there is no
|
|
// right edge for a stretch to push against.
|
|
layout->addSpacing(kDeconstructGapPx);
|
|
|
|
// Having no cost, it shows its name where the building buttons show theirs
|
|
// (REQ-UI-DECONSTRUCT-BUTTON), and its Q toggle as the badge (REQ-UI-HOTKEYS).
|
|
const ButtonFace deconstructFace = buildButtonFace(
|
|
loadChipPixmaps(*m_buildingIcons, "deconstruct"),
|
|
QStringLiteral("Q"), tr("Deconstruct"), tr("Deconstruct"), QPixmap(),
|
|
font(), palette());
|
|
|
|
m_deconstructButton = new QPushButton(this);
|
|
m_deconstructButton->setCheckable(true);
|
|
m_deconstructButton->setIcon(deconstructFace.icon);
|
|
m_deconstructButton->setIconSize(deconstructFace.size);
|
|
// Refund tooltip composed from world.refund_percentage (REQ-UI-DECONSTRUCT-BUTTON,
|
|
// REQ-BLD-DECONSTRUCT). A finished building refunds the configured percentage; a
|
|
// construction site removed before it is built is refunded in full. When the
|
|
// percentage is 100% both cases coincide, so the tooltip is simplified to one case.
|
|
const int refundPercentage = m_config->world.refundPercentage;
|
|
const QString deconstructTooltip = (refundPercentage >= 100)
|
|
? tr("Deconstruct buildings. Refunds %1% of the building block cost.")
|
|
.arg(refundPercentage)
|
|
: tr("Deconstruct buildings. A finished building refunds %1% of its building "
|
|
"block cost once removed; a construction site removed before it is built "
|
|
"is refunded in full.")
|
|
.arg(refundPercentage);
|
|
// Hover only: the click toggles the mode (REQ-UI-TOOLTIP-TRIGGER).
|
|
TooltipTrigger::attachText(*m_deconstructButton, deconstructTooltip,
|
|
TooltipTrigger::Trigger::HoverOnly);
|
|
layout->addWidget(m_deconstructButton);
|
|
connect(m_deconstructButton, &QPushButton::clicked, this, [this]() {
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<DeconstructModeToggleRequestedEvent>());
|
|
});
|
|
|
|
updateVisibility();
|
|
registerForEvents();
|
|
}
|
|
|
|
BuildButtonBar::~BuildButtonBar()
|
|
{
|
|
unregisterForEvents();
|
|
}
|
|
|
|
void BuildButtonBar::placeIn(const QRect& viewRect,
|
|
const std::vector<QRect>& /*occupiedRects*/)
|
|
{
|
|
if (viewRect.isNull())
|
|
{
|
|
return;
|
|
}
|
|
// The layout drops hidden buttons from its size hint, but only once it has been
|
|
// re-run: an unlock changes which buttons are shown, and Qt would not get around to
|
|
// it before the bar is measured here.
|
|
layout()->activate();
|
|
|
|
const QSize barSize = sizeHint();
|
|
// Centered, except that a bar wider than the view stays flush with its left edge
|
|
// rather than hanging off both sides.
|
|
const int x = qMax(viewRect.left(),
|
|
viewRect.left() + (viewRect.width() - barSize.width()) / 2);
|
|
const int y = viewRect.bottom() - kBottomMarginPx - barSize.height() + 1;
|
|
setGeometry(QRect(QPoint(x, y), barSize));
|
|
}
|
|
|
|
void BuildButtonBar::clearActiveButton()
|
|
{
|
|
if (m_activeIndex)
|
|
{
|
|
m_buttons[*m_activeIndex]->setChecked(false);
|
|
}
|
|
m_activeIndex.reset();
|
|
}
|
|
|
|
void BuildButtonBar::updateAffordability()
|
|
{
|
|
const int buildingBlocks = m_sim->getBuildingBlocksStock();
|
|
|
|
// If the currently selected tool can no longer be afforded, exit builder mode
|
|
// before recomputing button states so it does not stay selected. Clearing the
|
|
// active index first lets the loop below disable the now-unaffordable button.
|
|
if (m_activeIndex)
|
|
{
|
|
const BuildingType activeType = m_types[*m_activeIndex];
|
|
const std::map<BuildingType, int>::const_iterator it = m_costs.find(activeType);
|
|
const int cost = (it != m_costs.end()) ? it->second : 0;
|
|
if (buildingBlocks < cost)
|
|
{
|
|
clearActiveButton();
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<ExitBuilderModeRequestedEvent>());
|
|
}
|
|
}
|
|
|
|
for (std::size_t i = 0; i < m_buttons.size(); ++i)
|
|
{
|
|
const BuildingType type = m_types[i];
|
|
const std::map<BuildingType, int>::const_iterator it = m_costs.find(type);
|
|
const int cost = (it != m_costs.end()) ? it->second : 0;
|
|
m_buttons[i]->setEnabled(buildingBlocks >= cost || m_activeIndex == i);
|
|
}
|
|
}
|
|
|
|
void BuildButtonBar::updateVisibility()
|
|
{
|
|
// A locked building type's button is hidden until its unlock group is awarded
|
|
// (REQ-LOCK-BUILDING). Buttons keep their index so hotkeys/affordability stay
|
|
// stable; they simply appear once the type is unlocked.
|
|
for (std::size_t i = 0; i < m_buttons.size(); ++i)
|
|
{
|
|
m_buttons[i]->setVisible(m_sim->isBuildingUnlocked(m_types[i]));
|
|
}
|
|
// A hidden button leaves the row, so the bar takes up a new width and has to be
|
|
// re-centered on it -- and the widgets that keep clear of the bar have to be placed
|
|
// against that new rect too, so the whole pass is re-run (REQ-UI-BUILD-BAR).
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<FloatingLayoutInvalidatedEvent>());
|
|
}
|
|
|
|
void BuildButtonBar::onBuildButton(int index)
|
|
{
|
|
if (index < 0 || index >= static_cast<int>(m_buttons.size()))
|
|
{
|
|
return;
|
|
}
|
|
const std::size_t idx = static_cast<std::size_t>(index);
|
|
|
|
if (m_activeIndex == idx)
|
|
{
|
|
clearActiveButton();
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<ExitBuilderModeRequestedEvent>());
|
|
return;
|
|
}
|
|
|
|
if (m_activeIndex)
|
|
{
|
|
m_buttons[*m_activeIndex]->setChecked(false);
|
|
}
|
|
|
|
m_activeIndex = idx;
|
|
m_buttons[idx]->setChecked(true);
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<BuildingTypeSelectedEvent>(m_types[idx]));
|
|
}
|
|
|
|
void BuildButtonBar::handleEvent(std::shared_ptr<const BuilderModeExitedEvent> /*event*/)
|
|
{
|
|
clearActiveButton();
|
|
}
|
|
|
|
void BuildButtonBar::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
|
|
{
|
|
updateAffordability();
|
|
}
|
|
|
|
void BuildButtonBar::handleEvent(std::shared_ptr<const UnlockedBuildingsChangedEvent> /*event*/)
|
|
{
|
|
updateVisibility();
|
|
updateAffordability();
|
|
}
|
|
|
|
void BuildButtonBar::handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event)
|
|
{
|
|
m_deconstructButton->setChecked(event->active);
|
|
}
|
|
|
|
void BuildButtonBar::handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event)
|
|
{
|
|
for (std::size_t i = 0; i < m_types.size(); ++i)
|
|
{
|
|
if (m_types[i] == event->type)
|
|
{
|
|
// Equivalent to clicking the build button: a disabled (unaffordable) or
|
|
// hidden (locked, REQ-LOCK-BUILDING) button cannot be clicked, so the
|
|
// hotkey is likewise inert.
|
|
if (m_buttons[i]->isEnabled() && m_buttons[i]->isVisible())
|
|
{
|
|
onBuildButton(static_cast<int>(i));
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|