diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 180ba87..0e46eb4 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -14,6 +14,8 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.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}/BlueprintSelectionDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h @@ -45,6 +47,8 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.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}/BlueprintSelectionDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp diff --git a/src/ui/ControlActionText.cpp b/src/ui/ControlActionText.cpp new file mode 100644 index 0000000..f4e63b4 --- /dev/null +++ b/src/ui/ControlActionText.cpp @@ -0,0 +1,92 @@ +#include "ControlActionText.h" + +#include +#include + +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(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(); +} diff --git a/src/ui/ControlActionText.h b/src/ui/ControlActionText.h new file mode 100644 index 0000000..d5c3378 --- /dev/null +++ b/src/ui/ControlActionText.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#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); diff --git a/src/ui/ControlsPanel.cpp b/src/ui/ControlsPanel.cpp new file mode 100644 index 0000000..2201920 --- /dev/null +++ b/src/ui/ControlsPanel.cpp @@ -0,0 +1,239 @@ +#include "ControlsPanel.h" + +#include +#include +#include +#include +#include +#include + +#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 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 contextActions = getContextActions(context); + const std::vector 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); +} diff --git a/src/ui/ControlsPanel.h b/src/ui/ControlsPanel.h new file mode 100644 index 0000000..5e41c55 --- /dev/null +++ b/src/ui/ControlsPanel.h @@ -0,0 +1,83 @@ +#pragma once + +#include + +#include +#include +#include + +#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 " * " 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 m_shownContextActions; + std::vector 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; +}; diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 74d42c3..82b9369 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -1156,6 +1156,12 @@ ControlContext GameWorldView::getControlContext() const return context; } +QString GameWorldView::getActiveBlueprintName() const +{ + if (!m_buildMode.isBlueprintMode()) { return QString(); } + return m_buildMode.getBlueprint().name; +} + void GameWorldView::mousePressEvent(QMouseEvent* event) { const WorldCoordinates coordinates = getCoordinates(); diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 8828891..53cae3e 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -114,6 +114,10 @@ public: // build mode and the selection. 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: void initializeGL() override; void paintGL() override; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index e16096a..389f00e 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -29,6 +29,7 @@ #include "SchematicChoiceDialog.h" #include "HeaderBar.h" #include "SelectionPanel.h" +#include "ControlsPanel.h" #include "ShipLayoutBlueprintSerializer.h" #include "ShipLayoutDialog.h" #include "BuildingIconCache.h" @@ -91,6 +92,11 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, m_itemIcons.get(), m_buildingIcons.get(), 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 // dims the game behind modal dialogs/menus (REQ-UI-MODAL-DIM). 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). m_selectionPanel->anchorTo( 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); } diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index c1bf965..4a0e249 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -29,6 +29,7 @@ class Simulation; class GameWorldView; class HeaderBar; class SelectionPanel; +class ControlsPanel; class BuildButtonBar; class BlueprintLibrary; class BuildingIconCache; @@ -99,6 +100,7 @@ private: GameWorldView* m_gameWorldView; HeaderBar* m_headerBar; SelectionPanel* m_selectionPanel; + ControlsPanel* m_controlsPanel; BuildButtonBar* m_buildButtonBar; // 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).