3 Commits

Author SHA1 Message Date
fe69921b8e show the available controls in a panel over the world
The panel decides nothing about what the controls are: it asks the same
resolver the key handling and the mouse dispatch ask, and renders each badge
from the binding that resolver matches. A chip cannot claim a key that does
nothing, and a label cannot describe a click that does something else, because
neither is written here.

Display text is the one part that is not shared -- lib/core says what is
available and what triggers it, ControlActionText.cpp says what it is called.
That split is why the table can stay free of UI strings and still be the single
source of the pairing.

It refreshes on a timer rather than by subscribing. Two things that change a
row -- a belt drag starting, the ghost crossing a transfer target -- happen on
mouse movement and publish nothing, and they must still show while the game is
paused, so there is no event and no tick to hang it on. Resolving is comparing
two vectors of enums, and the rebuild is skipped unless they differ.

Left edge, bottom-aligned in the same band the selection panel is confined to,
so it clears the build bar's strip and never meets the panel on the opposite
edge. Clicking the heading collapses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-07 17:30:22 +02:00
3e79b09fec resolve mouse gestures through the action table too
The panel has to say what the left button does right now -- "Place" or "Apply
settings", "Select" or "Toggle deconstruct" -- and that answer lived in the
if-chain at the top of mousePressEvent, where the panel could not reach it.
So the chain becomes a switch on the resolved action, and the panel will read
the same resolver. Which branch runs is now decided in one place; what each
branch does is untouched, drag state machines and all.

Right-click gets the clearest win: cancel-the-drag versus leave-the-mode was
two nested conditions inspecting belt state, and is now the two actions the
table already distinguishes for the panel's sake.

Ctrl variants fall back to the plain gesture wherever nothing claims them,
which is what keeps Ctrl+click placing a building in builder mode and Ctrl+drag
deconstructing an area -- the modifier means something only where an action
says it does, rather than every handler re-deciding whether to ignore it.

Behaviour is unchanged. Full suite passes; the app runs clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-07 17:24:25 +02:00
77bbd58d02 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
2026-08-07 17:20:44 +02:00
20 changed files with 1479 additions and 112 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);

View File

@@ -16,6 +16,7 @@ add_files(
WorldCameraTest.cpp
SelectionControllerTest.cpp
BuildModeControllerTest.cpp
ControlActionTest.cpp
BuildingTest.cpp
BuildingConfigTest.cpp
ShipTest.cpp

View File

@@ -0,0 +1,328 @@
#include "catch.hpp"
#include <algorithm>
#include <vector>
#include "BuildModeController.h"
#include "BuildingType.h"
#include "ControlAction.h"
// REQ-UI-CONTROLS-ACCURACY. The panel and the input handling read one table, and these
// tests are what makes that pay: the round-trip case below fails the moment a row is
// shown whose binding resolves elsewhere, which is the drift the whole design exists to
// prevent. Everything here works on action ids -- the display strings live in the ui
// target and are not what can silently go wrong.
namespace
{
ControlContext generalContext()
{
return ControlContext();
}
ControlContext selectionContext(bool placeable = true)
{
ControlContext context;
context.selection = ControlSelection::Buildings;
context.selectionCount = 3;
context.placeableBuildingSelected = placeable;
return context;
}
ControlContext builderContext(BuildingType type)
{
ControlContext context;
context.mode = BuildMode::Builder;
context.builderType = type;
return context;
}
ControlContext blueprintContext(bool transfer = false)
{
ControlContext context;
context.mode = BuildMode::Blueprint;
context.hoveredGhostIsTransfer = transfer;
return context;
}
ControlContext deconstructContext()
{
ControlContext context;
context.mode = BuildMode::Deconstruct;
return context;
}
// A spread wide enough that every availability rule and every binding condition is
// exercised by the whole-table properties below.
std::vector<ControlContext> allContexts()
{
ControlContext beltDragging = builderContext(BuildingType::Belt);
beltDragging.draggingBelt = true;
ControlContext generalWithBlueprint = generalContext();
generalWithBlueprint.temporaryBlueprintExists = true;
ControlContext fieldSelection = selectionContext(false);
fieldSelection.selection = ControlSelection::FieldObjects;
return {generalContext(),
generalWithBlueprint,
selectionContext(),
fieldSelection,
builderContext(BuildingType::Belt),
builderContext(BuildingType::Assembler),
beltDragging,
blueprintContext(false),
blueprintContext(true),
deconstructContext()};
}
std::vector<ControlAction> shownActions(const ControlContext& context)
{
std::vector<ControlAction> actions = getContextActions(context);
const std::vector<ControlAction> always = getAlwaysAvailableActions(context);
actions.insert(actions.end(), always.begin(), always.end());
return actions;
}
bool contains(const std::vector<ControlAction>& actions, ControlAction action)
{
return std::find(actions.begin(), actions.end(), action) != actions.end();
}
} // namespace
TEST_CASE("ControlAction: every shown row is available", "[controls]")
{
for (const ControlContext& context : allContexts())
{
for (ControlAction action : shownActions(context))
{
REQUIRE(isControlActionAvailable(action, context));
}
}
}
TEST_CASE("ControlAction: no row is shown twice", "[controls]")
{
for (const ControlContext& context : allContexts())
{
std::vector<ControlAction> actions = shownActions(context);
std::vector<ControlAction> unique = actions;
std::sort(unique.begin(), unique.end());
unique.erase(std::unique(unique.begin(), unique.end()), unique.end());
REQUIRE(unique.size() == actions.size());
}
}
// The core anti-drift property: a row's badges are rendered from its bindings, so every
// binding a row advertises must actually trigger that row's action in that same context.
TEST_CASE("ControlAction: every advertised binding resolves back to its own action",
"[controls]")
{
for (const ControlContext& context : allContexts())
{
for (ControlAction action : shownActions(context))
{
const std::vector<ControlBinding> bindings =
getControlActionBindings(action, context);
REQUIRE_FALSE(bindings.empty());
for (const ControlBinding& binding : bindings)
{
if (binding.isMouse)
{
REQUIRE(resolveMouseAction(binding.mouse, context) == action);
}
else
{
REQUIRE(resolveKeyAction(binding.key, binding.modifiers, context)
== action);
}
}
}
}
}
// The other direction: an input that resolves to something must resolve to an action
// that is actually available there, so no input can trigger a no-op. This is weaker
// than "must be a shown row" on purpose -- a context may omit an available binding
// (Ctrl+click in the General context), which REQ-UI-CONTROLS-ACCURACY permits; what it
// may never do is act on an unavailable one.
TEST_CASE("ControlAction: every resolvable input is available", "[controls]")
{
const std::vector<MouseBinding> mouseBindings = {
MouseBinding::LeftClick, MouseBinding::LeftDrag, MouseBinding::CtrlLeftClick,
MouseBinding::CtrlLeftDrag, MouseBinding::RightClick};
for (const ControlContext& context : allContexts())
{
for (MouseBinding binding : mouseBindings)
{
const ControlAction action = resolveMouseAction(binding, context);
if (action != ControlAction::None)
{
REQUIRE(isControlActionAvailable(action, context));
}
}
const std::vector<int> keys = {Qt::Key_A, Qt::Key_D, Qt::Key_W, Qt::Key_S,
Qt::Key_Space, Qt::Key_C, Qt::Key_V, Qt::Key_R,
Qt::Key_Q, Qt::Key_Escape};
for (int key : keys)
{
for (Qt::KeyboardModifiers modifiers :
{Qt::KeyboardModifiers(Qt::NoModifier),
Qt::KeyboardModifiers(Qt::ShiftModifier),
Qt::KeyboardModifiers(Qt::ControlModifier)})
{
const ControlAction action = resolveKeyAction(key, modifiers, context);
if (action != ControlAction::None)
{
REQUIRE(isControlActionAvailable(action, context));
}
}
}
}
}
TEST_CASE("ControlAction: contexts are named by mode and selection", "[controls]")
{
REQUIRE(getControlContextKind(generalContext()) == ControlContextKind::General);
REQUIRE(getControlContextKind(selectionContext()) == ControlContextKind::Selection);
REQUIRE(getControlContextKind(builderContext(BuildingType::Belt))
== ControlContextKind::Build);
REQUIRE(getControlContextKind(blueprintContext()) == ControlContextKind::Blueprint);
REQUIRE(getControlContextKind(deconstructContext())
== ControlContextKind::Deconstruct);
}
TEST_CASE("ControlAction: Q enters deconstruct mode, or leaves the active one",
"[controls]")
{
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, generalContext())
== ControlAction::EnterDeconstruct);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, selectionContext())
== ControlAction::EnterDeconstruct);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, builderContext(BuildingType::Belt))
== ControlAction::ExitMode);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, blueprintContext())
== ControlAction::ExitMode);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, deconstructContext())
== ControlAction::ExitMode);
}
TEST_CASE("ControlAction: Ctrl distinguishes the chords, other modifiers do not",
"[controls]")
{
const ControlContext context = selectionContext();
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::NoModifier, context)
== ControlAction::CopyTemporary);
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::ControlModifier, context)
== ControlAction::CreateBlueprint);
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::ControlModifier, context)
== ControlAction::OpenBlueprints);
// Shift+A must still pan, as it always has -- matching modifiers exactly instead of
// testing Ctrl alone would silently swallow the press.
REQUIRE(resolveKeyAction(Qt::Key_A, Qt::ShiftModifier, context) == ControlAction::Move);
REQUIRE(resolveKeyAction(Qt::Key_R, Qt::ShiftModifier, blueprintContext())
== ControlAction::Rotate);
}
TEST_CASE("ControlAction: a transfer target turns the click into Apply settings",
"[controls]")
{
const ControlContext plain = blueprintContext(false);
const ControlContext transfer = blueprintContext(true);
REQUIRE(resolveMouseAction(MouseBinding::LeftClick, plain) == ControlAction::Place);
REQUIRE(resolveMouseAction(MouseBinding::LeftClick, transfer)
== ControlAction::ApplySettings);
REQUIRE(contains(getContextActions(plain), ControlAction::Place));
REQUIRE_FALSE(contains(getContextActions(plain), ControlAction::ApplySettings));
REQUIRE(contains(getContextActions(transfer), ControlAction::ApplySettings));
REQUIRE_FALSE(contains(getContextActions(transfer), ControlAction::Place));
}
TEST_CASE("ControlAction: a belt drag takes the right mouse button from ExitMode",
"[controls]")
{
ControlContext dragging = builderContext(BuildingType::Belt);
dragging.draggingBelt = true;
const ControlContext idle = builderContext(BuildingType::Belt);
REQUIRE(resolveMouseAction(MouseBinding::RightClick, idle) == ControlAction::ExitMode);
REQUIRE(resolveMouseAction(MouseBinding::RightClick, dragging)
== ControlAction::CancelBeltLine);
// Q still leaves the mode outright while the drag runs, so ExitMode stays shown --
// with its right-click badge dropped, since the button now means something else.
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, dragging) == ControlAction::ExitMode);
const std::vector<ControlBinding> idleBindings =
getControlActionBindings(ControlAction::ExitMode, idle);
const std::vector<ControlBinding> dragBindings =
getControlActionBindings(ControlAction::ExitMode, dragging);
REQUIRE(idleBindings.size() == 2);
REQUIRE(dragBindings.size() == 1);
REQUIRE_FALSE(dragBindings.front().isMouse);
}
TEST_CASE("ControlAction: dragging a line is offered for belts only", "[controls]")
{
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag, builderContext(BuildingType::Belt))
== ControlAction::PlaceBeltLine);
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag,
builderContext(BuildingType::Assembler))
== ControlAction::None);
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag, blueprintContext())
== ControlAction::None);
}
TEST_CASE("ControlAction: blueprint keys are offered only where they do something",
"[controls]")
{
// V does nothing until C has captured something (REQ-UI-BLUEPRINT-TEMP).
ControlContext withBlueprint = generalContext();
withBlueprint.temporaryBlueprintExists = true;
REQUIRE_FALSE(contains(shownActions(generalContext()), ControlAction::PasteTemporary));
REQUIRE(contains(shownActions(withBlueprint), ControlAction::PasteTemporary));
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::NoModifier, generalContext())
== ControlAction::None);
// C and Ctrl+C need a selection holding something placeable (REQ-UI-HOTKEYS).
ControlContext fieldSelection = selectionContext(false);
fieldSelection.selection = ControlSelection::FieldObjects;
REQUIRE(contains(getContextActions(selectionContext()), ControlAction::CopyTemporary));
REQUIRE_FALSE(contains(getContextActions(fieldSelection), ControlAction::CopyTemporary));
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::NoModifier, fieldSelection)
== ControlAction::None);
// Ctrl+V opens the dialog whatever is going on (REQ-UI-HOTKEYS).
for (const ControlContext& context : allContexts())
{
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::ControlModifier, context)
== ControlAction::OpenBlueprints);
}
}
TEST_CASE("ControlAction: selection rows appear only once something is selected",
"[controls]")
{
const std::vector<ControlAction> general = getContextActions(generalContext());
const std::vector<ControlAction> selection = getContextActions(selectionContext());
REQUIRE(contains(general, ControlAction::Select));
REQUIRE(contains(general, ControlAction::EnterDeconstruct));
REQUIRE_FALSE(contains(general, ControlAction::AddToSelection));
REQUIRE_FALSE(contains(general, ControlAction::CreateBlueprint));
REQUIRE(contains(selection, ControlAction::AddToSelection));
REQUIRE(contains(selection, ControlAction::AddAreaToSelection));
REQUIRE(contains(selection, ControlAction::CreateBlueprint));
REQUIRE(contains(selection, ControlAction::EnterDeconstruct));
}

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

@@ -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

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

@@ -1,6 +1,7 @@
#include "GameWorldView.h"
#include "PlacementRules.h"
#include "FactoryQueries.h"
#include "BlueprintLibrary.h"
#include <algorithm>
#include <cctype>
@@ -73,6 +74,23 @@
namespace
{
// The action a left-button gesture triggers, with the Ctrl variant falling back to the
// plain one wherever nothing is bound to it. That fallback is what keeps Ctrl+click
// placing a building in builder mode and Ctrl+drag deconstructing an area: the modifier
// only means something where an action claims it (REQ-UI-CONTROLS-CONTENT).
ControlAction resolveLeftGesture(bool controlHeld, bool isDrag,
const ControlContext& context)
{
if (controlHeld)
{
const ControlAction action = resolveMouseAction(
isDrag ? MouseBinding::CtrlLeftDrag : MouseBinding::CtrlLeftClick, context);
if (action != ControlAction::None) { return action; }
}
return resolveMouseAction(isDrag ? MouseBinding::LeftDrag : MouseBinding::LeftClick,
context);
}
// Keep only the filter entries whose item type is currently unlocked
// (REQ-LOCK-UI-BLUEPRINT). An empty result means "accept all".
std::vector<ItemType> filterUnlockedItems(const std::vector<ItemType>& filter,
@@ -1075,7 +1093,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 +1105,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,36 +1119,93 @@ 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;
}
QString GameWorldView::getActiveBlueprintName() const
{
if (!m_buildMode.isBlueprintMode()) { return QString(); }
return m_buildMode.getBlueprint().name;
}
void GameWorldView::mousePressEvent(QMouseEvent* event)
{
const WorldCoordinates coordinates = getCoordinates();
const ControlContext context = getControlContext();
if (event->button() != Qt::LeftButton)
{
if (event->button() == Qt::RightButton)
{
if (m_buildMode.isBuilderMode() && m_buildMode.isDraggingBelt())
switch (resolveMouseAction(MouseBinding::RightClick, context))
{
// Cancel the in-progress belt drag without placing anything;
// stay in belt builder mode (REQ-BLD-BELT-DRAG).
case ControlAction::CancelBeltLine:
// Drop the in-progress path without placing anything; belt builder
// mode stays active (REQ-BLD-BELT-DRAG).
m_buildMode.cancelBeltDrag();
}
else if (m_buildMode.getMode() != BuildMode::None)
{
break;
case ControlAction::ExitMode:
m_buildMode.exitCurrentMode();
break;
default:
break;
}
}
return;
}
const QPoint tile = coordinates.widgetToTile(event->pos());
const bool controlHeld = (event->modifiers() & Qt::ControlModifier) != 0;
if (m_buildMode.isBuilderMode())
// A press begins the click gesture; whether it turns out to be a drag is settled on
// release, where the drag binding is resolved instead.
switch (resolveLeftGesture(controlHeld, /*isDrag*/ false, context))
{
if (m_buildMode.getBuilderType() == BuildingType::Belt)
case ControlAction::Place:
case ControlAction::ApplySettings:
if (m_buildMode.isBlueprintMode())
{
// Deferred placement: start the drag and show the path ghost; nothing
// is placed until release (REQ-BLD-BELT-DRAG).
placeBlueprintAtTile(tile);
}
else if (m_buildMode.getBuilderType() == BuildingType::Belt)
{
// Belts place by dragging, so the press only anchors the path and shows its
// ghost; nothing is placed until release, and a plain click is the one-tile
// case (REQ-BLD-BELT-DRAG).
m_buildMode.beginBeltDrag(tile);
m_cursorWorldPos = coordinates.widgetToWorld(event->pos());
recomputeBeltDragPath(tile);
@@ -1139,27 +1214,22 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
{
placeAtTile(tile);
}
}
else if (m_buildMode.isBlueprintMode())
{
placeBlueprintAtTile(tile);
}
else if (m_buildMode.isDeconstructMode())
{
break;
case ControlAction::ToggleDeconstruct:
// Start a deconstruct box drag; a plain click resolves as a 1x1 box on
// release (REQ-BLD-DECONSTRUCT-CLICK, REQ-BLD-DECONSTRUCT-BOX).
m_boxSelecting = true;
m_boxStartTile = tile;
m_boxCurrentTile = tile;
}
else
{
const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0;
break;
case ControlAction::Select:
case ControlAction::AddToSelection:
// Only a click that hit nothing starts a box drag. Starting one on a hit
// would re-resolve the same object as a 1x1 box on release and undo the
// click: a Ctrl+click would toggle the building off, then straight back on.
if (!selectAtPoint(tile, coordinates.widgetToWorld(event->pos()), ctrl))
if (!selectAtPoint(tile, coordinates.widgetToWorld(event->pos()), controlHeld))
{
// selectAtPoint has already cleared the selection unless Ctrl is
// preserving it for an additive drag.
@@ -1167,6 +1237,10 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
m_boxStartTile = tile;
m_boxCurrentTile = tile;
}
break;
default:
break;
}
}
@@ -1263,6 +1337,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())
{
@@ -1294,7 +1383,11 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
const std::vector<BuildingId> boxIds =
buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile);
if (m_buildMode.isDeconstructMode())
const bool controlHeld = (event->modifiers() & Qt::ControlModifier) != 0;
const ControlAction dragAction =
resolveLeftGesture(controlHeld, /*isDrag*/ true, getControlContext());
if (dragAction == ControlAction::DeconstructArea)
{
const FactoryState& factory = m_sim->getFactoryState();
@@ -1356,7 +1449,9 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
return;
}
selectInBox((event->modifiers() & Qt::ControlModifier) != 0);
// A Ctrl box adds and never deselects, where a plain one replaces
// (REQ-UI-MULTI-SELECT).
selectInBox(dragAction == ControlAction::AddAreaToSelection);
}
}

View File

@@ -64,6 +64,7 @@
struct Command;
struct ParsedReplay;
class BlueprintLibrary;
class ItemIconCache;
class ReplayPlayer;
class Simulation;
@@ -102,6 +103,21 @@ 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;
// 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;
@@ -280,6 +296,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

@@ -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"
@@ -77,6 +78,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
@@ -87,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);
@@ -169,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);
}

View File

@@ -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).