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

@@ -72,6 +72,7 @@ void BuildModeController::enterMode(BuildMode mode)
std::make_shared<BuilderModeExitedEvent>());
break;
case BuildMode::Blueprint:
m_hoveredGhostIsTransfer = false;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintModeExitedEvent>());
break;
@@ -249,6 +250,16 @@ void BuildModeController::setBlueprintGhostTile(QPoint tile)
m_blueprintGhostTile = tile;
}
bool BuildModeController::isHoveredGhostTransfer() const
{
return m_hoveredGhostIsTransfer;
}
void BuildModeController::setHoveredGhostTransfer(bool transfer)
{
m_hoveredGhostIsTransfer = transfer;
}
const std::optional<BuildingId>&
BuildModeController::getDeconstructHoverBuildingId() const
{

View File

@@ -95,6 +95,15 @@ public:
QPoint getBlueprintGhostTile() const;
void setBlueprintGhostTile(QPoint tile);
// Whether the ghost under the cursor would hand its settings to the building
// already there rather than place anything (REQ-UI-BLUEPRINT-TRANSFER). Classifying
// it needs the factory state, so the caller resolves it and stores the answer here,
// as with setGhostValidity. Kept here rather than recomputed per reader so the
// click, the ghost's colour, and the controls panel cannot disagree about what the
// cursor is over.
bool isHoveredGhostTransfer() const;
void setHoveredGhostTransfer(bool transfer);
// --- deconstruct mode -----------------------------------------------------
const std::optional<BuildingId>& getDeconstructHoverBuildingId() const;
void setDeconstructHoverBuildingId(std::optional<BuildingId> id);
@@ -118,6 +127,7 @@ private:
Blueprint m_blueprint;
QPoint m_blueprintGhostTile;
bool m_hoveredGhostIsTransfer = false;
std::optional<BuildingId> m_deconstructHoverBuildingId;
};

View File

@@ -18,6 +18,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.h
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.h
PARENT_SCOPE
)
@@ -33,6 +34,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.cpp
PARENT_SCOPE
)

View File

@@ -0,0 +1,263 @@
#include "ControlAction.h"
namespace
{
// A binding that a belt drag can take over. Availability is a property of the action,
// but a binding can be claimed by a different action while a gesture is in progress:
// right-click cancels the drag instead of leaving builder mode (REQ-BLD-BELT-DRAG), so
// ExitMode's right-click drops out and it is left with Q alone.
enum class BindingCondition
{
Always,
NotDraggingBelt
};
struct KeyBindingEntry
{
ControlAction action;
int key;
// Shown on the badge, and Ctrl additionally participates in matching. Every other
// modifier is display only -- see resolveKeyAction.
Qt::KeyboardModifiers modifiers;
};
struct MouseBindingEntry
{
ControlAction action;
MouseBinding binding;
BindingCondition condition;
};
// Resolution is first-match-wins over these tables, so an entry that must beat another
// on the same input is listed above it -- CancelBeltLine before ExitMode on the right
// mouse button. Everything else is disjoint by availability.
const KeyBindingEntry KEY_BINDINGS[] = {
{ControlAction::Move, Qt::Key_A, Qt::NoModifier},
{ControlAction::Move, Qt::Key_D, Qt::NoModifier},
{ControlAction::GameSpeed, Qt::Key_W, Qt::NoModifier},
{ControlAction::GameSpeed, Qt::Key_S, Qt::NoModifier},
{ControlAction::TogglePause, Qt::Key_Space, Qt::NoModifier},
{ControlAction::CopyTemporary, Qt::Key_C, Qt::NoModifier},
{ControlAction::CreateBlueprint, Qt::Key_C, Qt::ControlModifier},
{ControlAction::PasteTemporary, Qt::Key_V, Qt::NoModifier},
{ControlAction::OpenBlueprints, Qt::Key_V, Qt::ControlModifier},
// One binding, two badges: Shift picks the rotation direction and is read by the
// handler, the way a build hotkey's digit is (REQ-BLD-ROTATE). Both entries match
// the same press, and both resolve to the same action, so listing them twice costs
// nothing and is what puts "R" and "Shift+R" on the row.
{ControlAction::Rotate, Qt::Key_R, Qt::NoModifier},
{ControlAction::Rotate, Qt::Key_R, Qt::ShiftModifier},
{ControlAction::EnterDeconstruct, Qt::Key_Q, Qt::NoModifier},
{ControlAction::ExitMode, Qt::Key_Q, Qt::NoModifier},
{ControlAction::OpenMenu, Qt::Key_Escape, Qt::NoModifier},
};
const MouseBindingEntry MOUSE_BINDINGS[] = {
{ControlAction::Select, MouseBinding::LeftClick, BindingCondition::Always},
{ControlAction::Place, MouseBinding::LeftClick, BindingCondition::Always},
{ControlAction::ApplySettings, MouseBinding::LeftClick, BindingCondition::Always},
{ControlAction::ToggleDeconstruct, MouseBinding::LeftClick, BindingCondition::Always},
{ControlAction::SelectArea, MouseBinding::LeftDrag, BindingCondition::Always},
{ControlAction::PlaceBeltLine, MouseBinding::LeftDrag, BindingCondition::Always},
{ControlAction::DeconstructArea, MouseBinding::LeftDrag, BindingCondition::Always},
{ControlAction::AddToSelection, MouseBinding::CtrlLeftClick, BindingCondition::Always},
{ControlAction::AddAreaToSelection, MouseBinding::CtrlLeftDrag, BindingCondition::Always},
{ControlAction::CancelBeltLine, MouseBinding::RightClick, BindingCondition::Always},
{ControlAction::ExitMode, MouseBinding::RightClick, BindingCondition::NotDraggingBelt},
};
bool isConditionMet(BindingCondition condition, const ControlContext& context)
{
if (condition == BindingCondition::NotDraggingBelt) { return !context.draggingBelt; }
return true;
}
bool isPlacementMode(const ControlContext& context)
{
return context.mode == BuildMode::Builder || context.mode == BuildMode::Blueprint;
}
std::vector<ControlAction> filterAvailable(const std::vector<ControlAction>& actions,
const ControlContext& context)
{
std::vector<ControlAction> available;
for (ControlAction action : actions)
{
if (isControlActionAvailable(action, context)) { available.push_back(action); }
}
return available;
}
} // namespace
bool isControlActionAvailable(ControlAction action, const ControlContext& context)
{
switch (action)
{
case ControlAction::None:
return false;
case ControlAction::Move:
case ControlAction::GameSpeed:
case ControlAction::TogglePause:
case ControlAction::OpenBlueprints:
case ControlAction::OpenMenu:
return true;
// Does nothing until something has been captured with C, so it is not offered
// before then (REQ-UI-BLUEPRINT-TEMP, REQ-UI-CONTROLS-ACCURACY).
case ControlAction::PasteTemporary:
return context.temporaryBlueprintExists;
case ControlAction::Select:
case ControlAction::SelectArea:
case ControlAction::AddToSelection:
case ControlAction::AddAreaToSelection:
case ControlAction::EnterDeconstruct:
return context.mode == BuildMode::None;
// Both need something a blueprint can be made of; a selection of ships or debris
// leaves them inert (REQ-UI-HOTKEYS).
case ControlAction::CopyTemporary:
case ControlAction::CreateBlueprint:
return context.mode == BuildMode::None && context.placeableBuildingSelected;
// Place and ApplySettings are the same click; which one it is depends on whether
// the ghost under the cursor is a transfer target (REQ-UI-BLUEPRINT-TRANSFER).
case ControlAction::Place:
return isPlacementMode(context) && !context.hoveredGhostIsTransfer;
case ControlAction::ApplySettings:
return context.mode == BuildMode::Blueprint && context.hoveredGhostIsTransfer;
// The only building type placed by dragging (REQ-BLD-BELT-DRAG).
case ControlAction::PlaceBeltLine:
return context.mode == BuildMode::Builder
&& context.builderType == BuildingType::Belt;
case ControlAction::Rotate:
return isPlacementMode(context);
case ControlAction::CancelBeltLine:
return context.draggingBelt;
case ControlAction::ExitMode:
return context.mode != BuildMode::None;
case ControlAction::ToggleDeconstruct:
case ControlAction::DeconstructArea:
return context.mode == BuildMode::Deconstruct;
}
return false;
}
std::vector<ControlBinding> getControlActionBindings(ControlAction action,
const ControlContext& context)
{
std::vector<ControlBinding> bindings;
for (const MouseBindingEntry& entry : MOUSE_BINDINGS)
{
if (entry.action != action) { continue; }
if (!isConditionMet(entry.condition, context)) { continue; }
ControlBinding binding;
binding.isMouse = true;
binding.mouse = entry.binding;
bindings.push_back(binding);
}
for (const KeyBindingEntry& entry : KEY_BINDINGS)
{
if (entry.action != action) { continue; }
ControlBinding binding;
binding.key = entry.key;
binding.modifiers = entry.modifiers;
bindings.push_back(binding);
}
return bindings;
}
ControlContextKind getControlContextKind(const ControlContext& context)
{
switch (context.mode)
{
case BuildMode::Builder: return ControlContextKind::Build;
case BuildMode::Blueprint: return ControlContextKind::Blueprint;
case BuildMode::Deconstruct: return ControlContextKind::Deconstruct;
case BuildMode::None: break;
}
return context.selection == ControlSelection::None ? ControlContextKind::General
: ControlContextKind::Selection;
}
std::vector<ControlAction> getContextActions(const ControlContext& context)
{
// The candidates of each context, in the order REQ-UI-CONTROLS-CONTENT lists them,
// then filtered by availability.
//
// The General and Selection lists differ rather than one filtered list serving
// both, because the additive-selection rows are an omission and not an
// unavailability: Ctrl+click does work with nothing selected, it just picks the
// object like a plain click would. "Add / remove from selection" is a row that
// means nothing until there is a selection to add to, so it waits for one
// (REQ-UI-CONTROLS-ACCURACY permits omitting an available binding).
switch (getControlContextKind(context))
{
case ControlContextKind::Build:
case ControlContextKind::Blueprint:
return filterAvailable({ControlAction::Place, ControlAction::ApplySettings,
ControlAction::PlaceBeltLine, ControlAction::Rotate,
ControlAction::CancelBeltLine, ControlAction::ExitMode},
context);
case ControlContextKind::Deconstruct:
return filterAvailable({ControlAction::ToggleDeconstruct,
ControlAction::DeconstructArea, ControlAction::ExitMode},
context);
case ControlContextKind::Selection:
return filterAvailable({ControlAction::Select, ControlAction::SelectArea,
ControlAction::AddToSelection,
ControlAction::AddAreaToSelection,
ControlAction::EnterDeconstruct,
ControlAction::CopyTemporary,
ControlAction::CreateBlueprint},
context);
case ControlContextKind::General:
break;
}
return filterAvailable({ControlAction::Select, ControlAction::SelectArea,
ControlAction::EnterDeconstruct},
context);
}
std::vector<ControlAction> getAlwaysAvailableActions(const ControlContext& context)
{
return filterAvailable({ControlAction::Move, ControlAction::GameSpeed,
ControlAction::TogglePause, ControlAction::PasteTemporary,
ControlAction::OpenBlueprints, ControlAction::OpenMenu},
context);
}
ControlAction resolveKeyAction(int key, Qt::KeyboardModifiers modifiers,
const ControlContext& context)
{
// Ctrl distinguishes a chord from the bare key (Ctrl+C is not C); every other
// modifier is ignored, so Shift+A still pans and Shift+R still rotates. This is
// what the key handler has always done, and matching modifiers exactly instead
// would silently drop those presses.
const bool controlHeld = (modifiers & Qt::ControlModifier) != 0;
for (const KeyBindingEntry& entry : KEY_BINDINGS)
{
if (entry.key != key) { continue; }
const bool entryNeedsControl = (entry.modifiers & Qt::ControlModifier) != 0;
if (entryNeedsControl != controlHeld) { continue; }
if (isControlActionAvailable(entry.action, context)) { return entry.action; }
}
return ControlAction::None;
}
ControlAction resolveMouseAction(MouseBinding binding, const ControlContext& context)
{
for (const MouseBindingEntry& entry : MOUSE_BINDINGS)
{
if (entry.binding != binding) { continue; }
if (!isConditionMet(entry.condition, context)) { continue; }
if (isControlActionAvailable(entry.action, context)) { return entry.action; }
}
return ControlAction::None;
}

View File

@@ -0,0 +1,164 @@
#pragma once
#include <vector>
#include <Qt>
#include "BuildModeController.h"
#include "BuildingType.h"
// The single statement of what the player can do right now, and which input does it
// (REQ-UI-CONTROLS-CONTENT, REQ-UI-CONTROLS-ACCURACY).
//
// This file declares actions; it never performs them and it never names them. It knows
// an action's bindings and the situations in which it does something -- nothing about
// the simulation, the widgets, the events an action ends up firing, or the words shown
// to the player. Display text lives in ui/ControlActionText.h, which formats the
// bindings this hands it, so a badge is derived from the real binding rather than
// typed beside it.
//
// Three readers, all of them consuming this and none of them extending it:
//
// * ControlsPanel calls getContextActions()/getAlwaysAvailableActions() and draws them.
// * InputMapper calls resolveKeyAction() and fires the event the action stands for.
// * GameWorldView calls resolveMouseAction() and runs the branch it already ran.
//
// Two bindings are deliberately absent. Build hotkeys (REQ-UI-HOTKEYS) are advertised
// on the build buttons instead of in the panel, and InputMapper::getBuildHotkeyLabel
// already derives their badges from the same table the handler switches on, so they
// have no drift to fix. F3/F4 are development controls and appear nowhere
// (REQ-UI-CONTROLS-ACCURACY).
//
// When bindings become player-configurable, only the binding tables in the .cpp turn
// from hard-coded data into loaded data. The actions, the availability rules, the
// panel, and every handler are unaffected.
enum class ControlAction
{
None, // no action is bound to the queried input in the queried context
// Always available (REQ-UI-CONTROLS-CONTENT).
Move,
GameSpeed,
TogglePause,
PasteTemporary,
OpenBlueprints,
OpenMenu,
// No build mode active.
Select,
SelectArea,
AddToSelection,
AddAreaToSelection,
EnterDeconstruct,
// No build mode active, with something selected.
CopyTemporary,
CreateBlueprint,
// Builder and blueprint placement mode.
Place,
ApplySettings,
PlaceBeltLine,
Rotate,
CancelBeltLine,
ExitMode,
// Deconstruct mode.
ToggleDeconstruct,
DeconstructArea
};
// The mouse gestures that carry a binding. Each is a whole gesture rather than a raw
// event: a drag is one binding, not a press plus a release, because that is the unit
// the player and the panel both think in. Which events make up the gesture, and the
// state it runs on, stay with the widget that owns them.
enum class MouseBinding
{
LeftClick,
LeftDrag,
CtrlLeftClick,
CtrlLeftDrag,
RightClick
};
// Which selection category is held, mirroring REQ-UI-SELECTION-CATEGORIES without
// depending on SelectionController.
enum class ControlSelection
{
None,
Buildings,
FieldObjects
};
// Which card the panel is showing (REQ-UI-CONTROLS-CONTENT). Named rather than
// spelled, so the heading text stays a presentation concern.
enum class ControlContextKind
{
General,
Selection,
Build,
Blueprint,
Deconstruct
};
// Everything the availability rules are allowed to depend on, as a plain snapshot.
//
// Taking a snapshot rather than references to the live controllers is what keeps this
// testable without a world, and it is what stops an action from reaching into the
// simulation: if a rule needs a fact, the fact is named here and the caller supplies it.
struct ControlContext
{
BuildMode mode = BuildMode::None;
BuildingType builderType = BuildingType::Belt; // while mode == Builder
bool draggingBelt = false;
// A single-building blueprint whose ghost is over a configuration-transfer target,
// so clicking hands over settings rather than placing (REQ-UI-BLUEPRINT-TRANSFER).
bool hoveredGhostIsTransfer = false;
ControlSelection selection = ControlSelection::None;
int selectionCount = 0;
// At least one selected building is player-placeable, the condition under which
// C and Ctrl+C do anything (REQ-UI-HOTKEYS).
bool placeableBuildingSelected = false;
bool temporaryBlueprintExists = false;
};
// One input an action answers to, in structured form so the badge can be rendered from
// it. `modifiers` is what the badge shows; matching is looser than equality, see
// resolveKeyAction.
struct ControlBinding
{
bool isMouse = false;
MouseBinding mouse = MouseBinding::LeftClick;
int key = 0; // Qt::Key_*, when !isMouse
Qt::KeyboardModifiers modifiers = Qt::NoModifier;
};
// True when triggering the action in this context would do what its label says. The
// panel shows exactly the available actions, and the resolvers return only available
// ones, which is REQ-UI-CONTROLS-ACCURACY expressed as one function.
bool isControlActionAvailable(ControlAction action, const ControlContext& context);
// The inputs an action answers to, in the order the panel should badge them.
// Context-dependent because a binding can be taken over: while a belt drag is in
// progress the right mouse button cancels the drag, so ExitMode is left with its key
// binding alone (REQ-BLD-BELT-DRAG).
std::vector<ControlBinding> getControlActionBindings(ControlAction action,
const ControlContext& context);
// Which card is showing, and the rows it holds -- the context's own, then the block
// available everywhere (REQ-UI-CONTROLS-CARD). Both lists are already filtered to the
// available actions and ordered as REQ-UI-CONTROLS-CONTENT lists them.
ControlContextKind getControlContextKind(const ControlContext& context);
std::vector<ControlAction> getContextActions(const ControlContext& context);
std::vector<ControlAction> getAlwaysAvailableActions(const ControlContext& context);
// The action a key press or a mouse gesture triggers here, or None when the input is
// unbound in this context. Both return only actions that are available, so a caller can
// act on the result without re-checking the situation.
//
// Rotation direction is not part of the action: R and Shift+R are one Rotate, and the
// caller reads the modifier for the direction, exactly as the digit of a build hotkey
// is read from the key. An action with a parameter keeps the parameter at the handler.
ControlAction resolveKeyAction(int key, Qt::KeyboardModifiers modifiers,
const ControlContext& context);
ControlAction resolveMouseAction(MouseBinding binding, const ControlContext& context);