resolve keyboard shortcuts through one action table instead of a switch

The controls panel needs to say what each key does right now, and a panel that
keeps its own list of that is a list that goes stale. So the list moves into
lib/core/ControlAction.h: which actions exist, what each is bound to, and when
each does something. InputMapper stops deciding that and switches on the
resolved action instead, so the panel and the key handling cannot disagree
about what Q means -- there is only one place that says.

The table declares; it never performs. It holds no simulation access, fires no
events, and names nothing: display strings live in the ui target, which formats
the bindings this hands it, so a badge is rendered from the real binding rather
than typed beside it. What an action *does* stays exactly where it was.

ControlContext is the snapshot the rules read, which is what keeps this
testable without a world. Two of its facts come from BlueprintLibrary, which is
built after the world view and so arrives by setter; one comes from the new
hovered-transfer flag on BuildModeController, resolved once on mouse-move
through the same classifier the click and the ghost colour already use.

Behaviour is unchanged, deliberately. Ctrl still separates the chords and every
other modifier is still ignored, so Shift+A pans as before; matching modifiers
exactly would have silently swallowed those presses. Build hotkeys and F3/F4
stay outside the table -- the first are advertised on the build buttons and
already derive their badges from the handler's own table, the second are
development controls the panel must never offer.

The tests are the point of putting this in lib: every row's bindings must
resolve back to that row's action in that same context, which fails the moment
a shown row and its handler part ways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
This commit is contained in:
2026-08-07 17:20:44 +02:00
parent 02f2314588
commit 77bbd58d02
14 changed files with 953 additions and 88 deletions

View File

@@ -39,6 +39,11 @@ bool BlueprintLibrary::getCanCaptureSelection() const
return selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds);
}
bool BlueprintLibrary::getHasTemporaryBlueprint() const
{
return m_temporaryBlueprint.has_value();
}
void BlueprintLibrary::saveSelectionAs(const QString& name)
{
Blueprint blueprint = createBlueprintFromSelection();

View File

@@ -44,6 +44,11 @@ public:
// (REQ-UI-BLUEPRINT-CREATE).
bool getCanCaptureSelection() const;
// True once C has captured something -- the condition under which V does anything
// (REQ-UI-BLUEPRINT-TEMP), and so the condition under which the controls panel
// offers it (REQ-UI-CONTROLS-ACCURACY).
bool getHasTemporaryBlueprint() const;
// Captures the current selection under the given name and appends it to the list.
// Silently does nothing when nothing player-placeable is selected.
void saveSelectionAs(const QString& name);

View File

@@ -1,6 +1,7 @@
#include "GameWorldView.h"
#include "PlacementRules.h"
#include "FactoryQueries.h"
#include "BlueprintLibrary.h"
#include <algorithm>
#include <cctype>
@@ -1075,7 +1076,7 @@ void GameWorldView::keyPressEvent(QKeyEvent* event)
// Keys are turned into actions and published by the input mapper
// (REQ-UI-HOTKEYS); this widget reacts to those as an ordinary subscriber, so
// nothing is handled here directly.
if (m_inputMapper.handleKeyPress(event)) { return; }
if (m_inputMapper.handleKeyPress(event, getControlContext())) { return; }
QOpenGLWidget::keyPressEvent(event);
}
@@ -1087,7 +1088,7 @@ void GameWorldView::keyReleaseEvent(QKeyEvent* event)
QOpenGLWidget::keyReleaseEvent(event);
return;
}
if (m_inputMapper.handleKeyRelease(event)) { return; }
if (m_inputMapper.handleKeyRelease(event, getControlContext())) { return; }
QOpenGLWidget::keyReleaseEvent(event);
}
@@ -1101,6 +1102,43 @@ void GameWorldView::focusOutEvent(QFocusEvent* event)
QOpenGLWidget::focusOutEvent(event);
}
void GameWorldView::setBlueprintLibrary(const BlueprintLibrary* library)
{
m_blueprintLibrary = library;
}
ControlContext GameWorldView::getControlContext() const
{
ControlContext context;
context.mode = m_buildMode.getMode();
context.draggingBelt = m_buildMode.isDraggingBelt();
context.hoveredGhostIsTransfer = m_buildMode.isHoveredGhostTransfer();
if (m_buildMode.isBuilderMode()) { context.builderType = m_buildMode.getBuilderType(); }
// Buildings win over field objects, so the two are never both non-empty
// (REQ-UI-SELECTION-CATEGORIES).
const std::vector<BuildingId>& buildings = m_selection.getSelectedBuildings();
const std::vector<entt::entity>& actors = m_selection.getSelectedActors();
const std::vector<entt::entity>& debris = m_selection.getSelectedDebris();
if (!buildings.empty())
{
context.selection = ControlSelection::Buildings;
context.selectionCount = static_cast<int>(buildings.size());
}
else if (!actors.empty() || !debris.empty())
{
context.selection = ControlSelection::FieldObjects;
context.selectionCount = static_cast<int>(actors.size() + debris.size());
}
if (m_blueprintLibrary)
{
context.placeableBuildingSelected = m_blueprintLibrary->getCanCaptureSelection();
context.temporaryBlueprintExists = m_blueprintLibrary->getHasTemporaryBlueprint();
}
return context;
}
void GameWorldView::mousePressEvent(QMouseEvent* event)
{
const WorldCoordinates coordinates = getCoordinates();
@@ -1263,6 +1301,21 @@ void GameWorldView::mouseMoveEvent(QMouseEvent* event)
else if (m_buildMode.isBlueprintMode())
{
m_buildMode.setBlueprintGhostTile(tile);
// Resolved here, once, through the same classifier the click and the ghost's
// colour use, and stored on the mode: the controls panel says "Apply settings"
// exactly when clicking would transfer (REQ-UI-BLUEPRINT-TRANSFER). Only a
// single-building blueprint hit-tests the cursor, so only it can be a hovered
// transfer target.
const std::vector<BlueprintBuilding>& buildings =
m_buildMode.getBlueprint().buildings;
bool transfer = false;
if (buildings.size() == 1)
{
transfer = resolveBlueprintGhostHere(buildings.front(), tile).action
== BlueprintGhostAction::Transfer;
}
m_buildMode.setHoveredGhostTransfer(transfer);
}
else if (m_buildMode.isDeconstructMode())
{

View File

@@ -64,6 +64,7 @@
struct Command;
struct ParsedReplay;
class BlueprintLibrary;
class ItemIconCache;
class ReplayPlayer;
class Simulation;
@@ -102,6 +103,17 @@ public:
void setGameSpeed(double multiplier);
void resetForNewGame();
// The blueprint library is constructed after this widget, so it arrives by setter.
// Not owned; supplies the two facts about blueprints that the control context needs
// (REQ-UI-CONTROLS-CONTENT).
void setBlueprintLibrary(const BlueprintLibrary* library);
// The player's current situation, as the one snapshot every reader of the control
// table works from: this widget's own mouse dispatch, the input mapper, and the
// controls panel (REQ-UI-CONTROLS-CONTENT). Built here because this widget owns the
// build mode and the selection.
ControlContext getControlContext() const;
protected:
void initializeGL() override;
void paintGL() override;
@@ -280,6 +292,8 @@ private:
// between them (REQ-UI-SELECTION-CATEGORIES), including publishing the change
// events. This widget only resolves what was hit.
SelectionController m_selection;
// Not owned; set after construction, so null until MainWindow has built it.
const BlueprintLibrary* m_blueprintLibrary = nullptr;
bool m_boxSelecting;
QPoint m_boxStartTile;
QPoint m_boxCurrentTile;

View File

@@ -9,6 +9,7 @@
#include "BlueprintSelectionRequestedEvent.h"
#include "BuildHotkeyPressedEvent.h"
#include "BuildingType.h"
#include "ControlAction.h"
#include "DebugDrawToggleRequestedEvent.h"
#include "EscapeMenuRequestedEvent.h"
#include "EventManager.h"
@@ -77,7 +78,7 @@ QString InputMapper::getBuildHotkeyLabel(BuildingType type)
return QString();
}
bool InputMapper::handleKeyPress(QKeyEvent* event)
bool InputMapper::handleKeyPress(QKeyEvent* event, const ControlContext& context)
{
// Auto-repeat says nothing new about which keys are down, and a held action is
// already held.
@@ -86,6 +87,9 @@ bool InputMapper::handleKeyPress(QKeyEvent* event)
// Number-key build-mode hotkeys (REQ-UI-HOTKEYS). nativeVirtualKey gives the
// physical digit independent of keyboard layout and Shift (with Shift held, key()
// for the number row can arrive as Key_Exclam etc.). VK_1..VK_9 = 0x31..0x39.
// Not part of the ControlAction table: these are advertised on the build buttons
// rather than in the controls panel, and getBuildHotkeyLabel already reads the
// same binding table this does (REQ-UI-CONTROLS-ACCURACY).
const quint32 virtualKey = event->nativeVirtualKey();
if (virtualKey >= 0x31 && virtualKey <= 0x39)
{
@@ -100,107 +104,102 @@ bool InputMapper::handleKeyPress(QKeyEvent* event)
}
}
// Blueprint chords (REQ-UI-HOTKEYS). Checked ahead of the plain-key switch below,
// which binds bare A/D/W/S/R/Q/C/V and must not fire on a Ctrl chord -- bare C and V
// are the temporary-blueprint counterparts of these two (REQ-UI-BLUEPRINT-TEMP). Both
// requests are decided by MainWindow, the only widget that can pause the game and dim
// the window for a modal.
if ((event->modifiers() & Qt::ControlModifier) != 0)
{
switch (event->key())
{
case Qt::Key_C:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintSaveRequestedEvent>());
return true;
case Qt::Key_V:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintSelectionRequestedEvent>());
return true;
default:
break;
}
}
// Development controls, deliberately outside the table so they are never offered
// to the player (REQ-UI-CONTROLS-ACCURACY).
switch (event->key())
{
case Qt::Key_A:
m_panLeftHeld = true;
updatePanDirection();
return true;
case Qt::Key_D:
m_panRightHeld = true;
updatePanDirection();
return true;
case Qt::Key_Space:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<PauseToggleRequestedEvent>());
return true;
case Qt::Key_W:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SpeedStepRequestedEvent>(+1));
return true;
case Qt::Key_S:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SpeedStepRequestedEvent>(-1));
return true;
case Qt::Key_R:
// Shift reverses the rotation direction (REQ-BLD-ROTATE).
EventManager::getInstance()->sendEventImmediately(
std::make_shared<GhostRotationRequestedEvent>(
(event->modifiers() & Qt::ShiftModifier) != 0));
return true;
case Qt::Key_Q:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ModeCancelRequestedEvent>());
return true;
case Qt::Key_C:
// Capture a temporary blueprint from the current selection (REQ-UI-BLUEPRINT-TEMP).
// The BlueprintLibrary owns the selection and blueprint-capture logic; it decides
// whether anything placeable is selected and drives placement mode from there.
EventManager::getInstance()->sendEventImmediately(
std::make_shared<TemporaryBlueprintCaptureRequestedEvent>());
return true;
case Qt::Key_V:
// Re-enter placement mode for the temporary blueprint captured with C, if there is
// one (REQ-UI-BLUEPRINT-TEMP). The library holds it; nothing is captured here.
EventManager::getInstance()->sendEventImmediately(
std::make_shared<TemporaryBlueprintPlaceRequestedEvent>());
return true;
case Qt::Key_F3:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DebugDrawToggleRequestedEvent>());
return true;
case Qt::Key_Escape:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EscapeMenuRequestedEvent>());
return true;
case Qt::Key_F4:
EventManager::getInstance()->addEvent(
std::make_shared<TracePrintRequestedEvent>());
return true;
default:
break;
}
// Everything else: the key names an action, the action names an event. Which key
// is bound to what, and whether it does anything in this situation, are both the
// table's business -- this switch only knows what each action means
// (REQ-UI-HOTKEYS, REQ-UI-CONTROLS-ACCURACY).
switch (resolveKeyAction(event->key(), event->modifiers(), context))
{
case ControlAction::Move:
// A parameter of the action rather than an action of its own: the table binds
// both keys to Move and the direction is read off the key here, as the rotation
// direction and the build hotkey's digit are.
if (event->key() == Qt::Key_A) { m_panLeftHeld = true; }
else { m_panRightHeld = true; }
updatePanDirection();
return true;
case ControlAction::GameSpeed:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SpeedStepRequestedEvent>(event->key() == Qt::Key_W ? +1 : -1));
return true;
case ControlAction::TogglePause:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<PauseToggleRequestedEvent>());
return true;
case ControlAction::Rotate:
// Shift reverses the rotation direction (REQ-BLD-ROTATE).
EventManager::getInstance()->sendEventImmediately(
std::make_shared<GhostRotationRequestedEvent>(
(event->modifiers() & Qt::ShiftModifier) != 0));
return true;
case ControlAction::EnterDeconstruct:
case ControlAction::ExitMode:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ModeCancelRequestedEvent>());
return true;
case ControlAction::CopyTemporary:
// The BlueprintLibrary owns the selection and blueprint-capture logic; it drives
// placement mode from there (REQ-UI-BLUEPRINT-TEMP).
EventManager::getInstance()->sendEventImmediately(
std::make_shared<TemporaryBlueprintCaptureRequestedEvent>());
return true;
case ControlAction::PasteTemporary:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<TemporaryBlueprintPlaceRequestedEvent>());
return true;
case ControlAction::CreateBlueprint:
// Decided by MainWindow, the only widget that can pause the game and dim the
// window for a modal.
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintSaveRequestedEvent>());
return true;
case ControlAction::OpenBlueprints:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintSelectionRequestedEvent>());
return true;
case ControlAction::OpenMenu:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EscapeMenuRequestedEvent>());
return true;
default:
// Either nothing is bound to the key here, or what is bound is a mouse gesture
// the view handles. Unconsumed, so ordinary Qt shortcuts keep working.
return false;
}
}
bool InputMapper::handleKeyRelease(QKeyEvent* event)
bool InputMapper::handleKeyRelease(QKeyEvent* event, const ControlContext& context)
{
if (event->isAutoRepeat()) { return false; }
switch (event->key())
// Only held actions have a release worth acting on. Resolved through the table
// rather than matched against A and D directly, so the keys stay rebindable in one
// place rather than two.
if (resolveKeyAction(event->key(), event->modifiers(), context) != ControlAction::Move)
{
case Qt::Key_A:
m_panLeftHeld = false;
updatePanDirection();
return true;
case Qt::Key_D:
m_panRightHeld = false;
updatePanDirection();
return true;
default:
return false;
}
if (event->key() == Qt::Key_A) { m_panLeftHeld = false; }
else { m_panRightHeld = false; }
updatePanDirection();
return true;
}
void InputMapper::releaseAll()

View File

@@ -3,14 +3,19 @@
#include <QString>
#include "BuildingType.h"
#include "ControlAction.h"
#include "WorldCamera.h"
class QKeyEvent;
// Turns raw key events into the game's semantic actions and publishes them
// (REQ-UI-HOTKEYS). Widgets react to the action, never to the key, so the two can
// be rebound independently later; the bindings themselves are still hard-coded
// here for now.
// be rebound independently later.
//
// Which key means what is not decided here: the caller hands in a ControlContext and
// ControlAction.h resolves the press against it, so this file only knows what each
// action means once resolved. That is what keeps the controls panel and the key
// handling from drifting apart -- both read the one table (REQ-UI-CONTROLS-ACCURACY).
//
// Two output shapes, chosen by the nature of the action rather than by taste:
//
@@ -34,9 +39,10 @@ public:
static QString getBuildHotkeyLabel(BuildingType type);
// Both return true when the key was consumed; the caller passes anything else
// on to its base class so unrelated shortcuts keep working.
bool handleKeyPress(QKeyEvent* event);
bool handleKeyRelease(QKeyEvent* event);
// on to its base class so unrelated shortcuts keep working. `context` is the
// player's current situation, which decides what a key does (REQ-UI-CONTROLS-CONTENT).
bool handleKeyPress(QKeyEvent* event, const ControlContext& context);
bool handleKeyRelease(QKeyEvent* event, const ControlContext& context);
// Drops all held-key state, publishing the resulting change. Call when the
// receiving widget can no longer expect key-up events.

View File

@@ -77,6 +77,10 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
// because only it can pause the game and raise the dim overlay. Built after the
// world view because loading blueprints.toml may put a message box on screen.
m_blueprintLibrary = std::make_unique<BlueprintLibrary>(sim, &sim->getConfig(), this);
// Two facts about blueprints decide what the world view offers the player
// (REQ-UI-CONTROLS-CONTENT); the library is built after the view, so it is handed
// over here rather than passed to the constructor.
m_gameWorldView->setBlueprintLibrary(m_blueprintLibrary.get());
// Floats over the game world at its right edge rather than occupying a column of
// its own, and hides itself while nothing is selected (REQ-UI-SELECTION-PANEL). Like