diff --git a/docs/requirements.md b/docs/requirements.md index 1ebfde6..6531632 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -669,7 +669,7 @@ The controls panel tells the player which controls are available right now. It i - **Input.** Mouse events over the panel are consumed by the panel and never reach the game world: hovering it shows no builder-mode ghost at the tile beneath, and clicking it neither places a building nor changes the selection. Right-clicking the panel does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG). The only control the panel itself offers is the header click that collapses and expands it. - REQ-UI-CONTROLS-CARD: **Card structure.** The panel is a card with two parts, top to bottom: - **Header** — always shown, and the panel's only interactive element (REQ-UI-CONTROLS-PANEL). It holds a colored context dot on the left, the context's name beside it in upper case, and, for contexts that define one, a **detail suffix** separated by a middle dot (`BUILD MODE · Assembler`). The name and detail per context are given in REQ-UI-CONTROLS-CONTENT. - - **Rows** — one per available control, shown only while the panel is expanded. Each row is one or more **key badges** on the left — the key or mouse button drawn as a small bordered chip — and a **label** beside them naming what it does. An action reachable two ways carries both badges in the same row (`RMB` `Q` — Exit placement) rather than occupying two rows. A row whose action leaves the current mode is drawn with the destructive badge styling, distinguishing it from the rows that act within the mode. No row is ever drawn greyed or otherwise disabled: a control the player cannot currently use is not shown at all (REQ-UI-CONTROLS-ACCURACY). + - **Rows** — one per available control, shown only while the panel is expanded. Each row is one or more **key badges** on the left — the key or mouse button drawn as a small bordered chip — and a **label** beside them naming what it does. An action reachable two ways carries both badges in the same row (`RMB` `Q` — Exit placement) rather than occupying two rows. A row whose action hands the current context back — leaving a build mode, or clearing the selection (REQ-UI-SELECTION-EXCLUSIVE) — is drawn with the destructive badge styling, distinguishing it from the rows that act within the context; to the player both are the one key that backs out, so both are marked alike. No row is ever drawn greyed or otherwise disabled: a control the player cannot currently use is not shown at all (REQ-UI-CONTROLS-ACCURACY). The rows that are live in every context (REQ-UI-CONTROLS-CONTENT) are shown last, under a divider and the caption `ALWAYS AVAILABLE`. This holds in every context including the General one, which has context rows of its own above the divider like any other, so the card is read the same way wherever the player is. - REQ-UI-CONTROLS-CONTENT: **Content catalog.** The control context follows from the active build mode and the selection alone. Build modes are mutually exclusive (REQ-BLD-BUILDER-MODE), so exactly one context applies at any moment: @@ -684,6 +684,8 @@ The controls panel tells the player which controls are available right now. It i The Selection context's detail counts the selection and names its category (REQ-UI-SELECTION-CATEGORIES): ` buildings` for a building selection, ` objects` for a field selection, in the singular at a count of one. The Build and Blueprint contexts show **the same rows** and differ only in their header. + **The row that hands the context back is last.** Where a context has one — `Q` — Clear selection in the Selection context, `RMB` `Q` — Exit placement in Build and Blueprint, `RMB` `Q` — Exit deconstruct mode in Deconstruct — it is the final context row, below the rows that act within the context, and it carries the destructive badge styling (REQ-UI-CONTROLS-CARD). The General context has none: with no mode to leave and nothing selected, its `Q` row enters deconstruct mode rather than leaving anything, and is an ordinary row that happens to come last. + **Always-available rows**, shown in every context: | Badges | Label | Shown | @@ -706,9 +708,9 @@ The controls panel tells the player which controls are available right now. It i | | `LMB` drag | Select area | | | `Ctrl` `LMB` | Add / remove from selection | | | `Ctrl` `LMB` drag | Add area to selection | - | | `Q` | Clear selection | | | `C` | Copy to temporary blueprint | | | `Ctrl` `C` | Create blueprint | + | | `Q` | Clear selection | | Build, Blueprint | `LMB` | Place | | | `LMB` drag | Place belt line | | | `R` / `Shift` `R` | Rotate | diff --git a/src/lib/core/ControlAction.cpp b/src/lib/core/ControlAction.cpp index d1f1832..0a6f3f6 100644 --- a/src/lib/core/ControlAction.cpp +++ b/src/lib/core/ControlAction.cpp @@ -31,7 +31,9 @@ struct MouseBindingEntry // 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. +// mouse button. Everything else is disjoint by availability: Q carries three actions +// whose availability rules partition the situations between them, so their order here is +// the order the reader meets them and nothing more (REQ-UI-HOTKEYS). const KeyBindingEntry KEY_BINDINGS[] = { {ControlAction::Move, Qt::Key_A, Qt::NoModifier}, {ControlAction::Move, Qt::Key_D, Qt::NoModifier}, @@ -48,8 +50,9 @@ const KeyBindingEntry KEY_BINDINGS[] = { // 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::ClearSelection, Qt::Key_Q, Qt::NoModifier}, + {ControlAction::EnterDeconstruct, Qt::Key_Q, Qt::NoModifier}, {ControlAction::OpenMenu, Qt::Key_Escape, Qt::NoModifier}, }; @@ -114,9 +117,21 @@ bool isControlActionAvailable(ControlAction action, const ControlContext& contex case ControlAction::SelectArea: case ControlAction::AddToSelection: case ControlAction::AddAreaToSelection: - case ControlAction::EnterDeconstruct: return context.mode == BuildMode::None; + // The three cases of Q, in the order REQ-UI-HOTKEYS evaluates them: it leaves the + // active mode, else clears the selection, else enters deconstruct mode. A selection + // and a build mode never coexist (REQ-UI-SELECTION-EXCLUSIVE), so the first two are + // already disjoint; what the empty-selection condition below adds is the third step, + // which is why entering deconstruct mode by key takes two presses while something is + // selected -- the Deconstruct button still gets there in one click. + case ControlAction::ClearSelection: + return context.mode == BuildMode::None + && context.selection != ControlSelection::None; + case ControlAction::EnterDeconstruct: + return context.mode == BuildMode::None + && context.selection == ControlSelection::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: @@ -209,13 +224,16 @@ std::vector getContextActions(const ControlContext& context) return filterAvailable({ControlAction::ToggleDeconstruct, ControlAction::DeconstructArea, ControlAction::ExitMode}, context); + // ClearSelection is listed last for the same reason ExitMode is above: the row that + // backs the player out of the context sits at the bottom of the context's rows + // wherever there is one (REQ-UI-CONTROLS-CONTENT). case ControlContextKind::Selection: return filterAvailable({ControlAction::Select, ControlAction::SelectArea, ControlAction::AddToSelection, ControlAction::AddAreaToSelection, - ControlAction::EnterDeconstruct, ControlAction::CopyTemporary, - ControlAction::CreateBlueprint}, + ControlAction::CreateBlueprint, + ControlAction::ClearSelection}, context); case ControlContextKind::General: break; diff --git a/src/lib/core/ControlAction.h b/src/lib/core/ControlAction.h index 4833548..bbcb481 100644 --- a/src/lib/core/ControlAction.h +++ b/src/lib/core/ControlAction.h @@ -54,6 +54,7 @@ enum class ControlAction // No build mode active, with something selected. CopyTemporary, CreateBlueprint, + ClearSelection, // Builder and blueprint placement mode. Place, diff --git a/src/lib/eventsystem/event/CMakeLists.txt b/src/lib/eventsystem/event/CMakeLists.txt index 4bf7f03..349c550 100644 --- a/src/lib/eventsystem/event/CMakeLists.txt +++ b/src/lib/eventsystem/event/CMakeLists.txt @@ -25,6 +25,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/SpeedStepRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/GhostRotationRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/ModeCancelRequestedEvent.h + ${CMAKE_CURRENT_SOURCE_DIR}/SelectionClearRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggleRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingTypeSelectedEvent.h diff --git a/src/lib/eventsystem/event/SelectionClearRequestedEvent.h b/src/lib/eventsystem/event/SelectionClearRequestedEvent.h new file mode 100644 index 0000000..dc73232 --- /dev/null +++ b/src/lib/eventsystem/event/SelectionClearRequestedEvent.h @@ -0,0 +1,11 @@ +#pragma once + +#include "Event.h" + +// The player pressed the key that drops the current selection (REQ-UI-HOTKEYS). Separate +// from ModeCancelRequestedEvent although both are Q: which of the two a press means is +// settled by the action table, and an event that meant either would force the receiver to +// decide it a second time. +class SelectionClearRequestedEvent : public Event +{ +}; diff --git a/src/test/ControlActionTest.cpp b/src/test/ControlActionTest.cpp index dd818f1..d18c94b 100644 --- a/src/test/ControlActionTest.cpp +++ b/src/test/ControlActionTest.cpp @@ -198,19 +198,29 @@ TEST_CASE("ControlAction: contexts are named by mode and selection", "[controls] == ControlContextKind::Deconstruct); } -TEST_CASE("ControlAction: Q enters deconstruct mode, or leaves the active one", +// The three cases of Q, in the order REQ-UI-HOTKEYS evaluates them. +TEST_CASE("ControlAction: Q leaves the active mode, else clears, else deconstructs", "[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); + + // Whatever the selection holds: clearing it is not a buildings-only action. + ControlContext fieldSelection = selectionContext(false); + fieldSelection.selection = ControlSelection::FieldObjects; + REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, selectionContext()) + == ControlAction::ClearSelection); + REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, fieldSelection) + == ControlAction::ClearSelection); + + // Only with nothing to clear and no mode to leave does Q enter deconstruct mode -- + // which is what makes it two presses from a selection. + REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, generalContext()) + == ControlAction::EnterDeconstruct); } TEST_CASE("ControlAction: Ctrl distinguishes the chords, other modifiers do not", @@ -320,9 +330,14 @@ TEST_CASE("ControlAction: selection rows appear only once something is selected" REQUIRE(contains(general, ControlAction::EnterDeconstruct)); REQUIRE_FALSE(contains(general, ControlAction::AddToSelection)); REQUIRE_FALSE(contains(general, ControlAction::CreateBlueprint)); + REQUIRE_FALSE(contains(general, ControlAction::ClearSelection)); REQUIRE(contains(selection, ControlAction::AddToSelection)); REQUIRE(contains(selection, ControlAction::AddAreaToSelection)); REQUIRE(contains(selection, ControlAction::CreateBlueprint)); - REQUIRE(contains(selection, ControlAction::EnterDeconstruct)); + // Q clears here instead of entering deconstruct mode, and its row sits last, as the + // row that hands the context back does in every context (REQ-UI-CONTROLS-CONTENT). + REQUIRE(contains(selection, ControlAction::ClearSelection)); + REQUIRE_FALSE(contains(selection, ControlAction::EnterDeconstruct)); + REQUIRE(selection.back() == ControlAction::ClearSelection); } diff --git a/src/ui/ControlActionText.cpp b/src/ui/ControlActionText.cpp index f4e63b4..9740c3e 100644 --- a/src/ui/ControlActionText.cpp +++ b/src/ui/ControlActionText.cpp @@ -63,6 +63,7 @@ QString getControlActionLabel(ControlAction action, const ControlContext& contex 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::ClearSelection: return Strings::tr("Clear selection"); case ControlAction::Place: return Strings::tr("Place"); case ControlAction::ApplySettings: return Strings::tr("Apply settings"); case ControlAction::PlaceBeltLine: return Strings::tr("Place belt line"); diff --git a/src/ui/ControlsPanel.cpp b/src/ui/ControlsPanel.cpp index 8395d33..cfbdbf8 100644 --- a/src/ui/ControlsPanel.cpp +++ b/src/ui/ControlsPanel.cpp @@ -57,14 +57,17 @@ QWidget* makeRow(ControlAction action, const ControlContext& context, QWidget* p // Every badge is rendered from the binding the resolver matches, so a chip cannot // claim a key that does nothing (REQ-UI-CONTROLS-ACCURACY). The chips of the row - // that leaves the mode are the ones marked, not its label (REQ-UI-CONTROLS-CARD). - const bool exitsMode = (action == ControlAction::ExitMode); + // that leaves the context are the ones marked, not its label (REQ-UI-CONTROLS-CARD): + // leaving a build mode and clearing the selection are the same gesture to the player, + // one key that hands the context back, so they are marked alike. + const bool backsOut = (action == ControlAction::ExitMode + || action == ControlAction::ClearSelection); const std::vector bindings = getControlActionBindings(action, context); for (const ControlBinding& binding : bindings) { QLabel* badge = new QLabel(getControlBindingBadge(binding), row); - badge->setObjectName(exitsMode ? QStringLiteral("controlBadgeExit") - : QStringLiteral("controlBadge")); + badge->setObjectName(backsOut ? QStringLiteral("controlBadgeExit") + : QStringLiteral("controlBadge")); layout->addWidget(badge); } diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 4be69da..4a17101 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -1330,6 +1330,11 @@ void GameWorldView::selectInBox(bool additive) if (!additive) { m_selection.clearAll(); } } +void GameWorldView::clearSelectionForBuildMode() +{ + m_selection.clearAll(); +} + bool GameWorldView::isHoverLive() const { // underMouse() is false while the cursor sits on one of the floating panels, @@ -1726,6 +1731,7 @@ void GameWorldView::handleEvent(std::shared_ptr event) void GameWorldView::handleEvent(std::shared_ptr event) { + clearSelectionForBuildMode(); m_buildMode.enterBuilderMode(event->type); // A mode entered by hotkey usually leaves the cursor exactly where it was, and no // mouse move follows to place the ghost; entered from a build button it leaves the @@ -1740,12 +1746,16 @@ void GameWorldView::handleEvent(std::shared_ptr /*event*/) { + clearSelectionForBuildMode(); m_buildMode.toggleDeconstructMode(); refreshHover(); } void GameWorldView::handleEvent(std::shared_ptr event) { + // The blueprint arrives already built from the selection this drops + // (REQ-UI-BLUEPRINT-TEMP, REQ-UI-SELECTION-EXCLUSIVE). + clearSelectionForBuildMode(); m_buildMode.enterBlueprintMode(event->blueprint); refreshHover(); } @@ -1790,14 +1800,19 @@ void GameWorldView::handleEvent(std::shared_ptr /*event*/) { - // One key backs out of whichever mode is active, and enters deconstruct mode - // when none is (REQ-UI-HOTKEYS). - // One key backs out of whichever mode is active, and enters deconstruct mode - // when none is (REQ-UI-HOTKEYS). + // One key backs out of whichever mode is active, and enters deconstruct mode when + // none is (REQ-UI-HOTKEYS). The selection case of that key never reaches here: it + // resolves to its own event, so there is nothing left to clear by the time the + // fallthrough enters deconstruct mode (REQ-UI-SELECTION-EXCLUSIVE). if (m_buildMode.getMode() == BuildMode::None) { m_buildMode.toggleDeconstructMode(); } else { m_buildMode.exitCurrentMode(); } } +void GameWorldView::handleEvent(std::shared_ptr /*event*/) +{ + m_selection.clearAll(); +} + void GameWorldView::handleEvent(std::shared_ptr /*event*/) { m_debugDraw = !m_debugDraw; diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 35b8968..c08275e 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -37,6 +37,7 @@ #include "ModeCancelRequestedEvent.h" #include "PanDirectionChangedEvent.h" #include "PauseToggleRequestedEvent.h" +#include "SelectionClearRequestedEvent.h" #include "SpeedStepRequestedEvent.h" #include "DebugDrawToggledEvent.h" #include "ArtifactCountChangedEvent.h" @@ -83,6 +84,7 @@ class GameWorldView : public QOpenGLWidget, SpeedStepRequestedEvent, GhostRotationRequestedEvent, ModeCancelRequestedEvent, + SelectionClearRequestedEvent, DebugDrawToggleRequestedEvent, CommandRequestedEvent> { @@ -149,6 +151,7 @@ private: void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; @@ -226,6 +229,12 @@ private: // which is the only case that goes on to start a box drag. bool selectAtPoint(QPoint tile, QVector2D worldPos, bool additive); void selectInBox(bool additive); + // A build mode and a selection are mutually exclusive, so entering any mode drops + // the selection (REQ-UI-SELECTION-EXCLUSIVE). Called from each of the three events + // that enter a mode -- the only ways in, whichever button or key the player used. + // Clearing an empty selection publishes nothing, so the exiting half of the + // deconstruct toggle costs nothing. + void clearSelectionForBuildMode(); // Whether the cursor points at the game world at all: it does while it is over // this widget, and while a belt or box drag holds the button, which goes on // following the cursor onto the floating panels and past the window edge. diff --git a/src/ui/InputMapper.cpp b/src/ui/InputMapper.cpp index 1b936c3..8e59151 100644 --- a/src/ui/InputMapper.cpp +++ b/src/ui/InputMapper.cpp @@ -17,6 +17,7 @@ #include "ModeCancelRequestedEvent.h" #include "PanDirectionChangedEvent.h" #include "PauseToggleRequestedEvent.h" +#include "SelectionClearRequestedEvent.h" #include "SpeedStepRequestedEvent.h" #include "TemporaryBlueprintCaptureRequestedEvent.h" #include "TemporaryBlueprintPlaceRequestedEvent.h" @@ -153,6 +154,10 @@ bool InputMapper::handleKeyPress(QKeyEvent* event, const ControlContext& context EventManager::getInstance()->sendEventImmediately( std::make_shared()); return true; + case ControlAction::ClearSelection: + EventManager::getInstance()->sendEventImmediately( + std::make_shared()); + return true; case ControlAction::CopyTemporary: // The BlueprintLibrary owns the selection and blueprint-capture logic; it drives // placement mode from there (REQ-UI-BLUEPRINT-TEMP).