show the available controls in a panel over the world

This commit is contained in:
2026-08-07 18:48:01 +02:00
parent 68a43a7d98
commit 980fad272f
9 changed files with 466 additions and 0 deletions

View File

@@ -14,6 +14,8 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/ControlsPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/ControlActionText.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
@@ -45,6 +47,8 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ControlsPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ControlActionText.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp

View File

@@ -0,0 +1,92 @@
#include "ControlActionText.h"
#include <QCoreApplication>
#include <QKeySequence>
namespace
{
// tr() for a file of free functions. Q_DECLARE_TR_FUNCTIONS expands with access
// specifiers, so it needs a class rather than a namespace; this struct exists only to
// carry it and give the strings a single lupdate context.
struct Strings
{
Q_DECLARE_TR_FUNCTIONS(ControlActionText)
};
QString getMouseBadge(MouseBinding binding)
{
switch (binding)
{
case MouseBinding::LeftClick: return Strings::tr("LMB");
case MouseBinding::LeftDrag: return Strings::tr("LMB drag");
case MouseBinding::CtrlLeftClick: return Strings::tr("Ctrl+LMB");
case MouseBinding::CtrlLeftDrag: return Strings::tr("Ctrl+LMB drag");
case MouseBinding::RightClick: return Strings::tr("RMB");
}
return QString();
}
} // namespace
QString getControlBindingBadge(const ControlBinding& binding)
{
if (binding.isMouse) { return getMouseBadge(binding.mouse); }
// QKeySequence spells the modifiers and the key together and localizes them, which
// is what makes this stay correct once keys are rebindable: the chip is generated
// from the binding rather than typed next to it.
return QKeySequence(binding.key | static_cast<int>(binding.modifiers))
.toString(QKeySequence::NativeText);
}
QString getControlActionLabel(ControlAction action, const ControlContext& context)
{
switch (action)
{
case ControlAction::None: return QString();
case ControlAction::Move: return Strings::tr("Move");
case ControlAction::GameSpeed: return Strings::tr("Game speed");
case ControlAction::TogglePause: return Strings::tr("Toggle pause");
case ControlAction::PasteTemporary: return Strings::tr("Paste last");
case ControlAction::OpenBlueprints: return Strings::tr("Blueprints");
case ControlAction::OpenMenu: return Strings::tr("Menu");
// A click on empty space clears and a click on an object replaces; naming only the
// first would be a half-truth once something is selected.
case ControlAction::Select:
return context.selection == ControlSelection::None
? Strings::tr("Select")
: Strings::tr("Select / clear selection");
case ControlAction::SelectArea: return Strings::tr("Select area");
case ControlAction::AddToSelection: return Strings::tr("Add / remove from selection");
case ControlAction::AddAreaToSelection: return Strings::tr("Add area to selection");
case ControlAction::EnterDeconstruct: return Strings::tr("Deconstruct mode");
case ControlAction::CopyTemporary: return Strings::tr("Copy to temporary blueprint");
case ControlAction::CreateBlueprint: return Strings::tr("Create blueprint");
case ControlAction::Place: return Strings::tr("Place");
case ControlAction::ApplySettings: return Strings::tr("Apply settings");
case ControlAction::PlaceBeltLine: return Strings::tr("Place belt line");
case ControlAction::Rotate: return Strings::tr("Rotate");
case ControlAction::CancelBeltLine: return Strings::tr("Cancel belt line");
case ControlAction::ExitMode:
return context.mode == BuildMode::Deconstruct
? Strings::tr("Exit deconstruct mode")
: Strings::tr("Exit placement");
case ControlAction::ToggleDeconstruct: return Strings::tr("Toggle deconstruct");
case ControlAction::DeconstructArea: return Strings::tr("Deconstruct area");
}
return QString();
}
QString getControlContextName(ControlContextKind kind)
{
switch (kind)
{
case ControlContextKind::General: return Strings::tr("GENERAL");
case ControlContextKind::Selection: return Strings::tr("SELECTION");
case ControlContextKind::Build: return Strings::tr("BUILD MODE");
case ControlContextKind::Blueprint: return Strings::tr("BLUEPRINT MODE");
case ControlContextKind::Deconstruct: return Strings::tr("DECONSTRUCT MODE");
}
return QString();
}

View File

@@ -0,0 +1,26 @@
#pragma once
#include <QString>
#include "ControlAction.h"
// What the player is shown for the actions and bindings declared in ControlAction.h
// (REQ-UI-CONTROLS-CONTENT). Kept out of lib/core deliberately: that file decides what
// is available and what triggers it, this one decides what it is called, and only this
// half is presentation.
//
// A badge is rendered from the binding it belongs to rather than written beside it, so
// what the panel shows on a chip is what the resolver actually matches. When bindings
// become player-configurable, this is the only place that has to learn to spell a
// rebound key.
// The chip text for one binding: "Ctrl+V", "LMB drag", "RMB".
QString getControlBindingBadge(const ControlBinding& binding);
// What the action is called in this context. A few actions are named for their
// situation rather than their implementation -- one exit action reads "Exit placement"
// in a placement mode and "Exit deconstruct mode" in deconstruct mode.
QString getControlActionLabel(ControlAction action, const ControlContext& context);
// The heading, in the upper case the card shows it in (REQ-UI-CONTROLS-CARD).
QString getControlContextName(ControlContextKind kind);

239
src/ui/ControlsPanel.cpp Normal file
View 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);
}

83
src/ui/ControlsPanel.h Normal file
View File

@@ -0,0 +1,83 @@
#pragma once
#include <vector>
#include <QRect>
#include <QString>
#include <QWidget>
#include "ControlAction.h"
class GameWorldView;
class QLabel;
class QTimer;
class QVBoxLayout;
// Shows the controls available in the player's current situation
// (REQ-UI-CONTROLS-PANEL). The panel decides nothing: which rows apply is
// ControlAction.h's answer, the same one the key handling and the world view's mouse
// dispatch act on, so what is shown and what happens cannot part company
// (REQ-UI-CONTROLS-ACCURACY).
//
// It floats over the game world at the left edge, bottom-aligned within the band its
// owner hands it, and collapses to its heading when the heading is clicked.
//
// Refreshed on a timer rather than by subscribing to events: two of the things that
// change a row -- a belt drag starting, the ghost moving over a transfer target --
// happen on mouse movement and publish nothing, and they still have to be reflected
// while the game is paused, so there is no tick to hang it on either. The rebuild is
// skipped unless the resolved content actually differs, which is a vector of enums to
// compare.
class ControlsPanel : public QWidget
{
Q_OBJECT
public:
// Neither pointer is owned; both must outlive this widget. The view is the source
// of the control context, being the widget that owns the build mode and the
// selection.
ControlsPanel(const GameWorldView* view, QWidget* parent = nullptr);
// Confines the panel to the given band of the game world view: it left-aligns
// within it and sits on its bottom edge. The band is the world view less the strip
// the build button bar occupies, so the two never overlap and the bar never has to
// move (REQ-UI-CONTROLS-PANEL, REQ-UI-BUILD-BAR).
void anchorTo(const QRect& bandRect);
protected:
// Clicking the heading collapses and expands the panel (REQ-UI-CONTROLS-PANEL).
void mousePressEvent(QMouseEvent* event) override;
private:
// Re-resolves the context and rebuilds only if the rows or the heading changed.
void refresh();
// Replaces the rows with those of the current context.
void rebuild(const ControlContext& context);
// The heading's "<name> * <detail>" text for the context, detail omitted when the
// context has none.
QString getHeadingText(const ControlContext& context) const;
// Re-fits the panel to its content within the anchored band.
void refit();
const GameWorldView* m_view;
QLabel* m_heading;
QWidget* m_rows;
QVBoxLayout* m_rowsLayout;
QTimer* m_refreshTimer;
// What is currently drawn, so a refresh that resolves to the same thing does
// nothing. The always-available block is kept separately because the divider
// between the two is part of what is drawn.
QString m_shownHeading;
std::vector<ControlAction> m_shownContextActions;
std::vector<ControlAction> m_shownAlwaysActions;
// Survives context changes and simulation restarts; presentation only, never a
// command (REQ-UI-CONTROLS-PANEL).
bool m_collapsed = false;
// The band the panel confines itself to, in the coordinates of its parent; null
// until the owner has anchored it for the first time.
QRect m_bandRect;
};

View File

@@ -1156,6 +1156,12 @@ ControlContext GameWorldView::getControlContext() const
return context; return context;
} }
QString GameWorldView::getActiveBlueprintName() const
{
if (!m_buildMode.isBlueprintMode()) { return QString(); }
return m_buildMode.getBlueprint().name;
}
void GameWorldView::mousePressEvent(QMouseEvent* event) void GameWorldView::mousePressEvent(QMouseEvent* event)
{ {
const WorldCoordinates coordinates = getCoordinates(); const WorldCoordinates coordinates = getCoordinates();

View File

@@ -114,6 +114,10 @@ public:
// build mode and the selection. // build mode and the selection.
ControlContext getControlContext() const; ControlContext getControlContext() const;
// Name of the blueprint being placed, for the controls panel's heading. Empty while
// no blueprint is active and for the unnamed temporary one (REQ-UI-BLUEPRINT-TEMP).
QString getActiveBlueprintName() const;
protected: protected:
void initializeGL() override; void initializeGL() override;
void paintGL() override; void paintGL() override;

View File

@@ -29,6 +29,7 @@
#include "SchematicChoiceDialog.h" #include "SchematicChoiceDialog.h"
#include "HeaderBar.h" #include "HeaderBar.h"
#include "SelectionPanel.h" #include "SelectionPanel.h"
#include "ControlsPanel.h"
#include "ShipLayoutBlueprintSerializer.h" #include "ShipLayoutBlueprintSerializer.h"
#include "ShipLayoutDialog.h" #include "ShipLayoutDialog.h"
#include "BuildingIconCache.h" #include "BuildingIconCache.h"
@@ -91,6 +92,11 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
m_itemIcons.get(), m_buildingIcons.get(), m_itemIcons.get(), m_buildingIcons.get(),
this); this);
// Floats at the world view's opposite edge from the selection panel and reads the
// world view for the player's current situation (REQ-UI-CONTROLS-PANEL). Built
// after the view for the same stacking reason as the panels above it.
m_controlsPanel = new ControlsPanel(m_gameWorldView, this);
// Created last so it stacks above the other children; covers the whole window and // Created last so it stacks above the other children; covers the whole window and
// dims the game behind modal dialogs/menus (REQ-UI-MODAL-DIM). // dims the game behind modal dialogs/menus (REQ-UI-MODAL-DIM).
m_dimOverlay = new ModalDimOverlay(m_visuals.overlays.modalDim, this); m_dimOverlay = new ModalDimOverlay(m_visuals.overlays.modalDim, this);
@@ -173,6 +179,10 @@ void MainWindow::layoutPanels()
// move for it (REQ-UI-SELECTION-PANEL, REQ-UI-BUILD-BAR). // move for it (REQ-UI-SELECTION-PANEL, REQ-UI-BUILD-BAR).
m_selectionPanel->anchorTo( m_selectionPanel->anchorTo(
worldRect.adjusted(0, 0, 0, -m_buildButtonBar->getStripHeightPx())); worldRect.adjusted(0, 0, 0, -m_buildButtonBar->getStripHeightPx()));
// Same band, opposite edge: left and bottom-aligned, so it clears the bar's strip
// too and never meets the selection panel (REQ-UI-CONTROLS-PANEL).
m_controlsPanel->anchorTo(
worldRect.adjusted(0, 0, 0, -m_buildButtonBar->getStripHeightPx()));
m_dimOverlay->setGeometry(0, 0, totalW, totalH); m_dimOverlay->setGeometry(0, 0, totalW, totalH);
} }

View File

@@ -29,6 +29,7 @@ class Simulation;
class GameWorldView; class GameWorldView;
class HeaderBar; class HeaderBar;
class SelectionPanel; class SelectionPanel;
class ControlsPanel;
class BuildButtonBar; class BuildButtonBar;
class BlueprintLibrary; class BlueprintLibrary;
class BuildingIconCache; class BuildingIconCache;
@@ -99,6 +100,7 @@ private:
GameWorldView* m_gameWorldView; GameWorldView* m_gameWorldView;
HeaderBar* m_headerBar; HeaderBar* m_headerBar;
SelectionPanel* m_selectionPanel; SelectionPanel* m_selectionPanel;
ControlsPanel* m_controlsPanel;
BuildButtonBar* m_buildButtonBar; BuildButtonBar* m_buildButtonBar;
// The saved blueprints themselves; they have no widget of their own any more and // The saved blueprints themselves; they have no widget of their own any more and
// are reached through the two modal dialogs (REQ-UI-BLUEPRINT-DIALOG). // are reached through the two modal dialogs (REQ-UI-BLUEPRINT-DIALOG).