show the available controls in a panel over the world
The panel decides nothing about what the controls are: it asks the same resolver the key handling and the mouse dispatch ask, and renders each badge from the binding that resolver matches. A chip cannot claim a key that does nothing, and a label cannot describe a click that does something else, because neither is written here. Display text is the one part that is not shared -- lib/core says what is available and what triggers it, ControlActionText.cpp says what it is called. That split is why the table can stay free of UI strings and still be the single source of the pairing. It refreshes on a timer rather than by subscribing. Two things that change a row -- a belt drag starting, the ghost crossing a transfer target -- happen on mouse movement and publish nothing, and they must still show while the game is paused, so there is no event and no tick to hang it on. Resolving is comparing two vectors of enums, and the rebuild is skipped unless they differ. Left edge, bottom-aligned in the same band the selection panel is confined to, so it clears the build bar's strip and never meets the panel on the opposite edge. Clicking the heading collapses it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
This commit is contained in:
239
src/ui/ControlsPanel.cpp
Normal file
239
src/ui/ControlsPanel.cpp
Normal file
@@ -0,0 +1,239 @@
|
||||
#include "ControlsPanel.h"
|
||||
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QMouseEvent>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "ControlActionText.h"
|
||||
#include "GameWorldView.h"
|
||||
#include "selection/SelectionNames.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
const int kMarginPx = 8; // between the band's edge and the panel
|
||||
const int kCardMarginPx = 8; // inside the panel, around its content
|
||||
const int kRefreshMs = 50; // see the class comment on why this polls
|
||||
|
||||
// Separates the heading's name from its detail, e.g. "BUILD MODE * Assembler".
|
||||
const QChar kHeadingSeparator(0x00B7); // U+00B7 MIDDLE DOT
|
||||
|
||||
// One row: the chips for the bindings, then what the action is called. Built as a plain
|
||||
// widget rather than a class of its own -- it holds no state and answers no questions.
|
||||
QWidget* makeRow(ControlAction action, const ControlContext& context, QWidget* parent)
|
||||
{
|
||||
QWidget* row = new QWidget(parent);
|
||||
QHBoxLayout* layout = new QHBoxLayout(row);
|
||||
layout->setContentsMargins(0, 2, 0, 2);
|
||||
layout->setSpacing(4);
|
||||
|
||||
// Every badge is rendered from the binding the resolver matches, so a chip cannot
|
||||
// claim a key that does nothing (REQ-UI-CONTROLS-ACCURACY).
|
||||
const std::vector<ControlBinding> bindings = getControlActionBindings(action, context);
|
||||
for (const ControlBinding& binding : bindings)
|
||||
{
|
||||
QLabel* badge = new QLabel(getControlBindingBadge(binding), row);
|
||||
badge->setObjectName(QStringLiteral("controlBadge"));
|
||||
layout->addWidget(badge);
|
||||
}
|
||||
|
||||
QLabel* label = new QLabel(getControlActionLabel(action, context), row);
|
||||
label->setObjectName(action == ControlAction::ExitMode
|
||||
? QStringLiteral("controlLabelExit")
|
||||
: QStringLiteral("controlLabel"));
|
||||
layout->addSpacing(4);
|
||||
layout->addWidget(label);
|
||||
layout->addStretch(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ControlsPanel::ControlsPanel(const GameWorldView* view, QWidget* parent)
|
||||
: QWidget(parent)
|
||||
, m_view(view)
|
||||
{
|
||||
// Floats over the rendered world, so it brings its own background to stay legible
|
||||
// over any world content (REQ-UI-CONTROLS-PANEL). Palette colors match the build
|
||||
// button bar's and the selection panel's chrome; like them this is widget chrome
|
||||
// rather than world rendering, so it is deliberately not a visuals.toml color. The
|
||||
// class scoped selector keeps the border on the panel rather than cascading onto
|
||||
// its children.
|
||||
setAttribute(Qt::WA_StyledBackground, true);
|
||||
setStyleSheet(QStringLiteral(
|
||||
"ControlsPanel { background-color: palette(window);"
|
||||
" border: 1px solid palette(mid); border-radius: 4px; }"
|
||||
"QLabel#controlHeading { font-weight: bold; letter-spacing: 1px;"
|
||||
" color: palette(text); }"
|
||||
"QLabel#controlBadge { border: 1px solid palette(mid); border-radius: 3px;"
|
||||
" padding: 1px 5px; font-family: monospace; color: palette(text); }"
|
||||
"QLabel#controlLabel { color: palette(text); }"
|
||||
// The row that leaves the mode reads differently from the ones that act within
|
||||
// it (REQ-UI-CONTROLS-CARD).
|
||||
"QLabel#controlLabelExit { color: palette(bright-text); }"
|
||||
"QLabel#controlCaption { color: palette(mid); font-size: 10px;"
|
||||
" letter-spacing: 1px; }"));
|
||||
|
||||
QVBoxLayout* outerLayout = new QVBoxLayout(this);
|
||||
outerLayout->setContentsMargins(kCardMarginPx, kCardMarginPx,
|
||||
kCardMarginPx, kCardMarginPx);
|
||||
outerLayout->setSpacing(4);
|
||||
|
||||
m_heading = new QLabel(this);
|
||||
m_heading->setObjectName(QStringLiteral("controlHeading"));
|
||||
m_heading->setCursor(Qt::PointingHandCursor);
|
||||
outerLayout->addWidget(m_heading);
|
||||
|
||||
m_rows = new QWidget(this);
|
||||
m_rowsLayout = new QVBoxLayout(m_rows);
|
||||
m_rowsLayout->setContentsMargins(0, 0, 0, 0);
|
||||
m_rowsLayout->setSpacing(0);
|
||||
outerLayout->addWidget(m_rows);
|
||||
|
||||
// Polling rather than subscribing; see the class comment.
|
||||
m_refreshTimer = new QTimer(this);
|
||||
connect(m_refreshTimer, &QTimer::timeout, this, &ControlsPanel::refresh);
|
||||
m_refreshTimer->start(kRefreshMs);
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
void ControlsPanel::anchorTo(const QRect& bandRect)
|
||||
{
|
||||
m_bandRect = bandRect;
|
||||
refit();
|
||||
}
|
||||
|
||||
void ControlsPanel::mousePressEvent(QMouseEvent* event)
|
||||
{
|
||||
// Only the heading toggles; a click anywhere else is swallowed so it never reaches
|
||||
// the world beneath (REQ-UI-CONTROLS-PANEL).
|
||||
if (m_heading->geometry().contains(event->pos()))
|
||||
{
|
||||
m_collapsed = !m_collapsed;
|
||||
m_rows->setVisible(!m_collapsed);
|
||||
refit();
|
||||
}
|
||||
event->accept();
|
||||
}
|
||||
|
||||
void ControlsPanel::refresh()
|
||||
{
|
||||
if (!m_view) { return; }
|
||||
|
||||
const ControlContext context = m_view->getControlContext();
|
||||
|
||||
const QString heading = getHeadingText(context);
|
||||
const std::vector<ControlAction> contextActions = getContextActions(context);
|
||||
const std::vector<ControlAction> alwaysActions = getAlwaysAvailableActions(context);
|
||||
|
||||
if (heading == m_shownHeading && contextActions == m_shownContextActions
|
||||
&& alwaysActions == m_shownAlwaysActions)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_shownHeading = heading;
|
||||
m_shownContextActions = contextActions;
|
||||
m_shownAlwaysActions = alwaysActions;
|
||||
rebuild(context);
|
||||
}
|
||||
|
||||
void ControlsPanel::rebuild(const ControlContext& context)
|
||||
{
|
||||
m_heading->setText(m_shownHeading);
|
||||
|
||||
// Rows are rebuilt wholesale rather than reconciled: a context change replaces
|
||||
// nearly all of them, and the panel redraws only when something actually changed.
|
||||
while (QLayoutItem* item = m_rowsLayout->takeAt(0))
|
||||
{
|
||||
delete item->widget();
|
||||
delete item;
|
||||
}
|
||||
|
||||
for (ControlAction action : m_shownContextActions)
|
||||
{
|
||||
m_rowsLayout->addWidget(makeRow(action, context, m_rows));
|
||||
}
|
||||
|
||||
// The always-available block sits under a divider, except in the General context,
|
||||
// where those rows and the context's own are the same kind of thing to a player
|
||||
// with nothing selected and no mode active (REQ-UI-CONTROLS-CARD).
|
||||
if (!m_shownAlwaysActions.empty()
|
||||
&& getControlContextKind(context) != ControlContextKind::General)
|
||||
{
|
||||
QFrame* divider = new QFrame(m_rows);
|
||||
divider->setFrameShape(QFrame::HLine);
|
||||
divider->setFrameShadow(QFrame::Plain);
|
||||
m_rowsLayout->addSpacing(6);
|
||||
m_rowsLayout->addWidget(divider);
|
||||
|
||||
QLabel* caption = new QLabel(tr("ALWAYS AVAILABLE"), m_rows);
|
||||
caption->setObjectName(QStringLiteral("controlCaption"));
|
||||
m_rowsLayout->addWidget(caption);
|
||||
}
|
||||
|
||||
for (ControlAction action : m_shownAlwaysActions)
|
||||
{
|
||||
m_rowsLayout->addWidget(makeRow(action, context, m_rows));
|
||||
}
|
||||
|
||||
m_rows->setVisible(!m_collapsed);
|
||||
refit();
|
||||
}
|
||||
|
||||
QString ControlsPanel::getHeadingText(const ControlContext& context) const
|
||||
{
|
||||
const ControlContextKind kind = getControlContextKind(context);
|
||||
const QString name = getControlContextName(kind);
|
||||
|
||||
QString detail;
|
||||
switch (kind)
|
||||
{
|
||||
case ControlContextKind::Build:
|
||||
detail = getBuildingTypeName(context.builderType);
|
||||
break;
|
||||
case ControlContextKind::Blueprint:
|
||||
{
|
||||
// The temporary blueprint is never named (REQ-UI-BLUEPRINT-TEMP), so it is
|
||||
// labelled by what it is rather than left blank.
|
||||
const QString blueprintName = m_view->getActiveBlueprintName();
|
||||
detail = blueprintName.isEmpty() ? tr("Temporary") : blueprintName;
|
||||
break;
|
||||
}
|
||||
case ControlContextKind::Selection:
|
||||
detail = context.selection == ControlSelection::Buildings
|
||||
? tr("%n building(s)", "", context.selectionCount)
|
||||
: tr("%n object(s)", "", context.selectionCount);
|
||||
break;
|
||||
case ControlContextKind::General:
|
||||
case ControlContextKind::Deconstruct:
|
||||
break;
|
||||
}
|
||||
|
||||
if (detail.isEmpty()) { return name; }
|
||||
return name + QStringLiteral(" ") + kHeadingSeparator + QStringLiteral(" ") + detail;
|
||||
}
|
||||
|
||||
void ControlsPanel::refit()
|
||||
{
|
||||
if (m_bandRect.isNull()) { return; }
|
||||
|
||||
// The layout drops hidden widgets from its size hint, but only once it has been
|
||||
// re-run: collapsing hides the rows before Qt would get around to it on its own.
|
||||
layout()->activate();
|
||||
|
||||
const QRect band = m_bandRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx);
|
||||
if (band.width() <= 0 || band.height() <= 0) { return; }
|
||||
|
||||
const QSize hint = sizeHint();
|
||||
const int widthPx = qMin(hint.width(), band.width());
|
||||
const int heightPx = qMin(hint.height(), band.height());
|
||||
|
||||
// Left edge of the band, sitting on its bottom edge, so the panel grows upward as
|
||||
// rows are added (REQ-UI-CONTROLS-PANEL).
|
||||
setGeometry(band.left(), band.bottom() - heightPx + 1, widthPx, heightPx);
|
||||
}
|
||||
Reference in New Issue
Block a user