resolve keyboard shortcuts through one action table instead of a switch

This commit is contained in:
2026-08-07 18:47:29 +02:00
parent f61f0bf761
commit 1f754de431
14 changed files with 953 additions and 88 deletions

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;
}