From a86cbd3fbdab5ed443ac7e4482654d06df96627f Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Thu, 6 Aug 2026 11:29:48 +0200 Subject: [PATCH] move blueprints out of the sidebar into Ctrl+C / Ctrl+V dialogs The blueprint panel is gone; the side panel column now holds the selected building panel alone at full height. Saving is Ctrl+C on the current selection, and picking one for placement is a new frameless modal card grid opened with Ctrl+V, or handed to directly after a confirmed save. BlueprintPanel sheds its widget half and becomes BlueprintLibrary: the list, its disk round-trip, capture, and the T hotkey, with no widget of its own. MainWindow owns it and drives both dialogs, because ModalPauseScope needs a GameWorldView and only MainWindow has one. Both handlers hold a single pause scope and a single dim scope across the save-to-select handoff, so the dim does not blink and the simulation is not resumed in between. The two facts a card derives -- the per-type contents summary and the plain cost total -- go into lib/sim next to captureBlueprintFromSelection so they can be unit tested; src/ui is off the test include path. They are kept apart from GameWorldView's placement total, which excludes locked types and rotate-in-place targets and is a different number by design. A card's delete icon is a sibling of the card body rather than one of its children: Qt disables a widget's children along with it, and the delete has to stay live on an unaffordable card. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K --- .../event/BlueprintSaveRequestedEvent.h | 11 + .../event/BlueprintSelectionRequestedEvent.h | 11 + src/lib/eventsystem/event/CMakeLists.txt | 2 + src/lib/sim/BuildingConfig.cpp | 72 ++++ src/lib/sim/BuildingConfig.h | 26 +- src/test/BlueprintTest.cpp | 4 +- src/test/BuildModeControllerTest.cpp | 2 +- src/test/BuildingConfigTest.cpp | 97 +++++ src/ui/BlueprintLibrary.cpp | 175 +++++++++ src/ui/BlueprintLibrary.h | 86 +++++ src/ui/BlueprintPanel.cpp | 281 --------------- src/ui/BlueprintPanel.h | 67 ---- src/ui/BlueprintSelectionDialog.cpp | 333 ++++++++++++++++++ src/ui/BlueprintSelectionDialog.h | 42 +++ src/ui/BuildButtonBar.cpp | 10 - src/ui/CMakeLists.txt | 6 +- src/ui/IconCaption.cpp | 8 + src/ui/IconCaption.h | 6 + src/ui/InputMapper.cpp | 25 +- src/ui/MainWindow.cpp | 76 +++- src/ui/MainWindow.h | 20 +- 21 files changed, 975 insertions(+), 385 deletions(-) create mode 100644 src/lib/eventsystem/event/BlueprintSaveRequestedEvent.h create mode 100644 src/lib/eventsystem/event/BlueprintSelectionRequestedEvent.h create mode 100644 src/ui/BlueprintLibrary.cpp create mode 100644 src/ui/BlueprintLibrary.h delete mode 100644 src/ui/BlueprintPanel.cpp delete mode 100644 src/ui/BlueprintPanel.h create mode 100644 src/ui/BlueprintSelectionDialog.cpp create mode 100644 src/ui/BlueprintSelectionDialog.h diff --git a/src/lib/eventsystem/event/BlueprintSaveRequestedEvent.h b/src/lib/eventsystem/event/BlueprintSaveRequestedEvent.h new file mode 100644 index 0000000..617a88d --- /dev/null +++ b/src/lib/eventsystem/event/BlueprintSaveRequestedEvent.h @@ -0,0 +1,11 @@ +#pragma once + +#include "Event.h" + +// Ctrl+C: save the current building selection as a named blueprint +// (REQ-UI-BLUEPRINT-CREATE, REQ-UI-HOTKEYS). MainWindow owns the modal flow, because +// it is the only widget that can pause the game and raise the dim overlay; whether +// anything placeable is selected is decided there, not by the key handler. +class BlueprintSaveRequestedEvent : public Event +{ +}; diff --git a/src/lib/eventsystem/event/BlueprintSelectionRequestedEvent.h b/src/lib/eventsystem/event/BlueprintSelectionRequestedEvent.h new file mode 100644 index 0000000..6b98711 --- /dev/null +++ b/src/lib/eventsystem/event/BlueprintSelectionRequestedEvent.h @@ -0,0 +1,11 @@ +#pragma once + +#include "Event.h" + +// Ctrl+V: open the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG, +// REQ-UI-HOTKEYS). The Ctrl+C path does not go through this event -- it opens the +// dialog directly so one pause scope and one dim scope span both dialogs +// (REQ-UI-MODAL-DIM). +class BlueprintSelectionRequestedEvent : public Event +{ +}; diff --git a/src/lib/eventsystem/event/CMakeLists.txt b/src/lib/eventsystem/event/CMakeLists.txt index b6c692b..0405e37 100644 --- a/src/lib/eventsystem/event/CMakeLists.txt +++ b/src/lib/eventsystem/event/CMakeLists.txt @@ -15,6 +15,8 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/UnlockedBuildingsChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h + ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSaveRequestedEvent.h + ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/PanDirectionChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/PauseToggleRequestedEvent.h diff --git a/src/lib/sim/BuildingConfig.cpp b/src/lib/sim/BuildingConfig.cpp index 6c4d110..2c99a5b 100644 --- a/src/lib/sim/BuildingConfig.cpp +++ b/src/lib/sim/BuildingConfig.cpp @@ -3,10 +3,14 @@ #include #include +#include +#include #include "BeltSystem.h" #include "Building.h" +#include "BuildingsConfig.h" #include "BuildingSystem.h" +#include "DisplayName.h" #include "Simulation.h" namespace @@ -49,6 +53,21 @@ std::optional resolvePlaceable(const Simulation& sim, Building resolved.bodyCells = building ? &building->bodyCells : &site->bodyCells; return resolved; } + +// Position of a building type in buildings.toml, which is the order the build button +// bar lays out its buttons (REQ-UI-BUILD-BAR) and so the tie-break order for a +// blueprint's contents line. A type with no config entry sorts after every known one. +std::size_t configOrderIndex(BuildingType type, const BuildingsConfig& buildings) +{ + for (std::size_t i = 0; i < buildings.buildings.size(); ++i) + { + if (buildings.buildings[i].type == type) + { + return i; + } + } + return buildings.buildings.size(); +} } // namespace std::optional readBuildingConfig(const Simulation& sim, BuildingId id) @@ -166,3 +185,56 @@ bool selectionHasPlaceableBuilding(const Simulation& sim, } return false; } + +std::vector summarizeBlueprintContents( + const Blueprint& blueprint, const BuildingsConfig& buildings) +{ + std::map counts; + for (const BlueprintBuilding& building : blueprint.buildings) + { + ++counts[building.type]; + } + + // The config index is carried through the sort so the comparator stays a strict + // total order; the enum value is the final tie-break, which only two config-less + // types could ever reach. + struct RankedType + { + BuildingType type; + int count; + std::size_t order; + }; + std::vector ranked; + ranked.reserve(counts.size()); + for (const std::pair& entry : counts) + { + ranked.push_back({entry.first, entry.second, + configOrderIndex(entry.first, buildings)}); + } + std::sort(ranked.begin(), ranked.end(), + [](const RankedType& left, const RankedType& right) + { + if (left.count != right.count) { return left.count > right.count; } + if (left.order != right.order) { return left.order < right.order; } + return static_cast(left.type) < static_cast(right.type); + }); + + std::vector summary; + summary.reserve(ranked.size()); + for (const RankedType& entry : ranked) + { + summary.push_back({toDisplayName(buildingTypeId(entry.type)), entry.count}); + } + return summary; +} + +int computeBlueprintCost(const Blueprint& blueprint, const BuildingsConfig& buildings) +{ + int total = 0; + for (const BlueprintBuilding& building : blueprint.buildings) + { + const BuildingDef* def = buildings.findBuildingDef(building.type); + if (def) { total += def->cost; } + } + return total; +} diff --git a/src/lib/sim/BuildingConfig.h b/src/lib/sim/BuildingConfig.h index d333372..281b467 100644 --- a/src/lib/sim/BuildingConfig.h +++ b/src/lib/sim/BuildingConfig.h @@ -11,6 +11,7 @@ #include "ShipLayout.h" class Simulation; +struct BuildingsConfig; // The user-configurable settings of a single building or construction site: the // selected recipe / ship schematic, the shipyard module layout, and (for @@ -49,6 +50,29 @@ Blueprint captureBlueprintFromSelection(const Simulation& sim, const std::vector& selectedIds); // True if any selected id refers to a player-placeable building or construction site -// (the enable condition for the Create Blueprint button, REQ-UI-BLUEPRINT-CREATE). +// (the condition under which Ctrl+C opens the blueprint save dialog, +// REQ-UI-BLUEPRINT-CREATE). bool selectionHasPlaceableBuilding(const Simulation& sim, const std::vector& selectedIds); + +// One " x " entry of a blueprint card's contents line +// (REQ-UI-BLUEPRINT-CARD). +struct BlueprintContentEntry +{ + std::string buildingName; + int count; +}; + +// Summarizes what a blueprint holds: one entry per building type it contains, ordered +// by descending count with ties broken by the order the types appear in buildings.toml +// (which is the order of the build button bar, REQ-UI-BUILD-BAR). Building types absent +// from the config sort last. Derived display data for the blueprint card, kept here so +// it is unit-testable rather than buried in the dialog (REQ-UI-BLUEPRINT-CARD). +std::vector summarizeBlueprintContents( + const Blueprint& blueprint, const BuildingsConfig& buildings); + +// Plain sum of the placement cost of every building in the blueprint +// (REQ-UI-BLUEPRINT-CARD). Distinct from the total charged on placement +// (REQ-UI-BLUEPRINT-PLACE), which additionally excludes locked building types and +// rotate-in-place targets; see GameWorldView's placement path. +int computeBlueprintCost(const Blueprint& blueprint, const BuildingsConfig& buildings); diff --git a/src/test/BlueprintTest.cpp b/src/test/BlueprintTest.cpp index b56906d..be29ec3 100644 --- a/src/test/BlueprintTest.cpp +++ b/src/test/BlueprintTest.cpp @@ -56,7 +56,7 @@ static Rotation rotCCW(Rotation r) return Rotation::East; } -// Mirror of BlueprintPanel::createBlueprintFromSelection: given per-building +// Mirror of BlueprintLibrary::createBlueprintFromSelection: given per-building // (anchor, bodyCells, type, rotation), compute Blueprint with floor-division // bounding-box center and per-building tile offsets. struct BuildingSpec @@ -149,7 +149,7 @@ static void applyRotationCCW(Blueprint& bp, const GameConfig& cfg) } } -// Mirrors BlueprintPanel::createBlueprintFromSelection's player-placeable filter: +// Mirrors BlueprintLibrary::createBlueprintFromSelection's player-placeable filter: // building types absent from buildings.toml (HQ, stations) or with playerPlaceable=false // are silently excluded before the bounding-box center and offsets are computed. static Blueprint buildBlueprintFiltered(const std::vector& specs, diff --git a/src/test/BuildModeControllerTest.cpp b/src/test/BuildModeControllerTest.cpp index e929f1c..9ed36da 100644 --- a/src/test/BuildModeControllerTest.cpp +++ b/src/test/BuildModeControllerTest.cpp @@ -82,7 +82,7 @@ TEST_CASE("Only one mode is active at a time", "[buildmode]") TEST_CASE("Entering builder mode announces that blueprint mode ended", "[buildmode]") { // Regression: entering builder mode used to drop the blueprint silently, so the - // blueprint panel kept its button highlighted for a mode that was over. + // blueprint library kept tracking an active blueprint for a mode that was over. BuildModeController controller; controller.enterBlueprintMode(makeBlueprint()); diff --git a/src/test/BuildingConfigTest.cpp b/src/test/BuildingConfigTest.cpp index df4dc8d..fe79508 100644 --- a/src/test/BuildingConfigTest.cpp +++ b/src/test/BuildingConfigTest.cpp @@ -139,3 +139,100 @@ TEST_CASE("readBuildingConfig returns nullopt for an unknown id", "[copyconfig]" Simulation sim(loadTestConfig(), 7); CHECK_FALSE(readBuildingConfig(sim, kInvalidBuildingId).has_value()); } + +// The derived facts a blueprint card shows (REQ-UI-BLUEPRINT-CARD). They live here +// rather than in the dialog so they can be tested: src/ui is off the test include path. + +namespace +{ +Blueprint makeBlueprint(const std::vector& types) +{ + Blueprint blueprint; + for (const BuildingType type : types) + { + BlueprintBuilding building; + building.type = type; + building.rotation = Rotation::East; + blueprint.buildings.push_back(building); + } + return blueprint; +} +} // namespace + +TEST_CASE("computeBlueprintCost sums the constituent placement costs", "[blueprint]") +{ + const GameConfig cfg = loadTestConfig(); + + // belt 2 + belt 2 + miner 15. + const Blueprint blueprint = makeBlueprint( + {BuildingType::Belt, BuildingType::Belt, BuildingType::Miner}); + CHECK(computeBlueprintCost(blueprint, cfg.buildings) == 19); +} + +TEST_CASE("computeBlueprintCost ignores types absent from buildings.toml", "[blueprint]") +{ + const GameConfig cfg = loadTestConfig(); + REQUIRE(cfg.buildings.findBuildingDef(BuildingType::Hq) == nullptr); + + const Blueprint blueprint = makeBlueprint({BuildingType::Belt, BuildingType::Hq}); + CHECK(computeBlueprintCost(blueprint, cfg.buildings) == 2); +} + +TEST_CASE("computeBlueprintCost is zero for an empty blueprint", "[blueprint]") +{ + const GameConfig cfg = loadTestConfig(); + CHECK(computeBlueprintCost(Blueprint{}, cfg.buildings) == 0); +} + +TEST_CASE("summarizeBlueprintContents orders by descending count", "[blueprint]") +{ + const GameConfig cfg = loadTestConfig(); + + const Blueprint blueprint = makeBlueprint( + {BuildingType::Belt, BuildingType::Smelter, BuildingType::Miner, + BuildingType::Miner, BuildingType::Smelter, BuildingType::Miner}); + + const std::vector summary = + summarizeBlueprintContents(blueprint, cfg.buildings); + REQUIRE(summary.size() == 3); + CHECK(summary[0].buildingName == "Miner"); + CHECK(summary[0].count == 3); + CHECK(summary[1].buildingName == "Smelter"); + CHECK(summary[1].count == 2); + CHECK(summary[2].buildingName == "Belt"); + CHECK(summary[2].count == 1); +} + +TEST_CASE("summarizeBlueprintContents breaks ties in buildings.toml order", "[blueprint]") +{ + const GameConfig cfg = loadTestConfig(); + + // Miner is declared after Belt in buildings.toml, which is the order the build + // button bar lays its buttons out, so Belt wins the tie despite the equal counts + // and despite sorting later by enum value. + const Blueprint blueprint = makeBlueprint( + {BuildingType::Miner, BuildingType::Belt, BuildingType::Miner, BuildingType::Belt}); + + const std::vector summary = + summarizeBlueprintContents(blueprint, cfg.buildings); + REQUIRE(summary.size() == 2); + CHECK(summary[0].buildingName == "Belt"); + CHECK(summary[1].buildingName == "Miner"); +} + +TEST_CASE("summarizeBlueprintContents spells multi-word ids as display names", "[blueprint]") +{ + const GameConfig cfg = loadTestConfig(); + + const Blueprint blueprint = makeBlueprint({BuildingType::TunnelEntry}); + const std::vector summary = + summarizeBlueprintContents(blueprint, cfg.buildings); + REQUIRE(summary.size() == 1); + CHECK(summary[0].buildingName == "Tunnel Entry"); +} + +TEST_CASE("summarizeBlueprintContents is empty for an empty blueprint", "[blueprint]") +{ + const GameConfig cfg = loadTestConfig(); + CHECK(summarizeBlueprintContents(Blueprint{}, cfg.buildings).empty()); +} diff --git a/src/ui/BlueprintLibrary.cpp b/src/ui/BlueprintLibrary.cpp new file mode 100644 index 0000000..13177e1 --- /dev/null +++ b/src/ui/BlueprintLibrary.cpp @@ -0,0 +1,175 @@ +#include "BlueprintLibrary.h" + +#include +#include + +#include +#include +#include +#include +#include + +#include "BlueprintPlacementRequestedEvent.h" +#include "BlueprintSerializer.h" +#include "EventManager.h" +#include "ExitBlueprintModeRequestedEvent.h" + +#include "BuildingConfig.h" +#include "Simulation.h" + +BlueprintLibrary::BlueprintLibrary(Simulation* sim, const GameConfig* config, + QWidget* dialogParent) + : m_sim(sim) + , m_config(config) + , m_dialogParent(dialogParent) +{ + loadFromDisk(); + registerForEvents(); +} + +BlueprintLibrary::~BlueprintLibrary() +{ + saveToDisk(); + unregisterForEvents(); +} + +bool BlueprintLibrary::getCanCaptureSelection() const +{ + // A construction site counts the same as an operational building (REQ-UI-BLUEPRINT-CREATE). + return selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds); +} + +void BlueprintLibrary::saveSelectionAs(const QString& name) +{ + Blueprint blueprint = createBlueprintFromSelection(); + if (blueprint.buildings.empty()) { return; } + + blueprint.name = name; + m_blueprints.push_back(std::move(blueprint)); +} + +const std::vector& BlueprintLibrary::getBlueprints() const +{ + return m_blueprints; +} + +QString BlueprintLibrary::getContentsSummary(int index) const +{ + if (index < 0 || index >= static_cast(m_blueprints.size())) { return QString(); } + + QStringList entries; + for (const BlueprintContentEntry& entry : summarizeBlueprintContents( + m_blueprints[static_cast(index)], m_config->buildings)) + { + // The same " x " notation the building multi-selection summary + // uses (REQ-UI-MULTI-SELECTION), which also sidesteps plural forms. + entries << QObject::tr("%1 x %2") + .arg(QString::fromStdString(entry.buildingName)) + .arg(entry.count); + } + return entries.join(QStringLiteral(", ")); +} + +int BlueprintLibrary::getCost(int index) const +{ + if (index < 0 || index >= static_cast(m_blueprints.size())) { return 0; } + return computeBlueprintCost(m_blueprints[static_cast(index)], + m_config->buildings); +} + +bool BlueprintLibrary::getCanAfford(int index) const +{ + return m_sim->getBuildingBlocksStock() >= getCost(index); +} + +void BlueprintLibrary::remove(int index) +{ + if (index < 0 || index >= static_cast(m_blueprints.size())) { return; } + + if (m_activeIndex == index) + { + m_activeIndex = std::nullopt; + EventManager::getInstance()->sendEventImmediately( + std::make_shared()); + } + else if (m_activeIndex.has_value() && *m_activeIndex > index) + { + --*m_activeIndex; + } + m_blueprints.erase(m_blueprints.begin() + index); +} + +void BlueprintLibrary::beginPlacement(int index) +{ + if (index < 0 || index >= static_cast(m_blueprints.size())) { return; } + + m_activeIndex = index; + EventManager::getInstance()->sendEventImmediately( + std::make_shared( + m_blueprints[static_cast(index)])); +} + +void BlueprintLibrary::handleEvent(std::shared_ptr event) +{ + m_selectedBuildingIds = event->ids; +} + +void BlueprintLibrary::handleEvent(std::shared_ptr /*event*/) +{ + m_activeIndex = std::nullopt; +} + +void BlueprintLibrary::handleEvent( + std::shared_ptr /*event*/) +{ + // Temporary blueprint (REQ-UI-BLUEPRINT-TEMP): build from the current selection and + // enter placement mode without adding it to the list or persisting it. If nothing + // player-placeable is selected, do nothing. + Blueprint blueprint = createBlueprintFromSelection(); + if (blueprint.buildings.empty()) { return; } + + // No saved blueprint is active while a temporary one is being placed. + m_activeIndex = std::nullopt; + EventManager::getInstance()->sendEventImmediately( + std::make_shared(std::move(blueprint))); +} + +Blueprint BlueprintLibrary::createBlueprintFromSelection() const +{ + // Capture is shared, testable logic in lib/sim: it resolves each selected id as an + // operational building or a construction site alike (REQ-UI-BLUEPRINT-CREATE, + // REQ-UI-BLUEPRINT-STORAGE). + return captureBlueprintFromSelection(*m_sim, m_selectedBuildingIds); +} + +void BlueprintLibrary::loadFromDisk() +{ + // Load at startup (REQ-UI-BLUEPRINT-LOAD). Missing file: start empty, no error. + const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml"; + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return; } + try + { + m_blueprints = BlueprintSerializer::deserialize(file.readAll().toStdString()); + } + catch (const std::exception& e) + { + QMessageBox::critical(m_dialogParent, QObject::tr("Load Failed"), + QObject::tr("Failed to load blueprints:\n%1").arg(e.what())); + m_blueprints.clear(); + } +} + +void BlueprintLibrary::saveToDisk() const +{ + // Persist on shutdown; write errors are silently ignored (REQ-UI-BLUEPRINT-SAVE). + const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml"; + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return; } + try + { + const std::string content = BlueprintSerializer::serialize(m_blueprints); + file.write(QByteArray::fromStdString(content)); + } + catch (...) {} +} diff --git a/src/ui/BlueprintLibrary.h b/src/ui/BlueprintLibrary.h new file mode 100644 index 0000000..33b7aab --- /dev/null +++ b/src/ui/BlueprintLibrary.h @@ -0,0 +1,86 @@ +#pragma once + +#include +#include + +#include + +#include "Blueprint.h" +#include "BlueprintModeExitedEvent.h" +#include "BuildingId.h" +#include "EventHandler.h" +#include "GameConfig.h" +#include "SelectionChangedEvent.h" +#include "TemporaryBlueprintRequestedEvent.h" + +class Simulation; +class QWidget; + +// The player's saved blueprints: the list itself, its persistence (REQ-UI-BLUEPRINT-SAVE, +// REQ-UI-BLUEPRINT-LOAD), capture from the current selection (REQ-UI-BLUEPRINT-CREATE, +// REQ-UI-BLUEPRINT-TEMP), and entry into blueprint placement mode. +// +// Deliberately not a widget: blueprints have no permanent place on screen any more +// (REQ-UI-BLUEPRINT-DIALOG), and the modal dialogs that present them must be driven from +// MainWindow, the only widget that can pause the game (ModalPauseScope) and raise the dim +// overlay. This class is the model those dialogs read and mutate. +class BlueprintLibrary : public CombinedEventHandler +{ +public: + // dialogParent parents the load-failure message box (REQ-UI-BLUEPRINT-LOAD) and is + // not owned. + BlueprintLibrary(Simulation* sim, const GameConfig* config, QWidget* dialogParent); + ~BlueprintLibrary(); + + // True when the current selection holds at least one player-placeable building or + // construction site -- the condition under which Ctrl+C has any effect + // (REQ-UI-BLUEPRINT-CREATE). + bool getCanCaptureSelection() 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); + + const std::vector& getBlueprints() const; + + // The blueprint's contents line, ready for its card: one " x " entry + // per building type, comma-separated, ordered by summarizeBlueprintContents + // (REQ-UI-BLUEPRINT-CARD). + QString getContentsSummary(int index) const; + + // Sum of the constituent buildings' placement costs (REQ-UI-BLUEPRINT-CARD). + int getCost(int index) const; + + // Whether the player can currently afford the blueprint's total cost, which is what + // enables or greys out its card (REQ-UI-BLUEPRINT-CARD). + bool getCanAfford(int index) const; + + // Removes the blueprint, exiting blueprint placement mode if it was the active one + // (REQ-UI-BLUEPRINT-DELETE). + void remove(int index); + + // Enters blueprint placement mode for the blueprint (REQ-UI-BLUEPRINT-MODE). + void beginPlacement(int index); + +private: + void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr event) override; + + Blueprint createBlueprintFromSelection() const; + void loadFromDisk(); + void saveToDisk() const; + + // The simulation is the single source of truth for the block stock and the + // selection; the change events are only refresh signals. + Simulation* m_sim; + const GameConfig* m_config; + QWidget* m_dialogParent; + std::vector m_selectedBuildingIds; + std::vector m_blueprints; + // Index of the blueprint currently in placement mode, so deleting it can exit that + // mode (REQ-UI-BLUEPRINT-DELETE). nullopt = no saved blueprint is being placed. + std::optional m_activeIndex; +}; diff --git a/src/ui/BlueprintPanel.cpp b/src/ui/BlueprintPanel.cpp deleted file mode 100644 index f6b8c2a..0000000 --- a/src/ui/BlueprintPanel.cpp +++ /dev/null @@ -1,281 +0,0 @@ -#include "BlueprintPanel.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "BlueprintPlacementRequestedEvent.h" -#include "BlueprintSerializer.h" -#include "BuildingBlocksChangedEvent.h" -#include "EventManager.h" -#include "ExitBlueprintModeRequestedEvent.h" - -#include "Building.h" -#include "BuildingConfig.h" -#include "BuildingSystem.h" -#include "Simulation.h" - -BlueprintPanel::BlueprintPanel(Simulation* sim, const GameConfig* config, QWidget* parent) - : QWidget(parent) - , m_sim(sim) - , m_config(config) -{ - QVBoxLayout* layout = new QVBoxLayout(this); - layout->setContentsMargins(4, 4, 4, 4); - layout->setSpacing(4); - - m_createBtn = new QPushButton(tr("Create Blueprint"), this); - m_createBtn->setFixedHeight(48); - m_createBtn->setEnabled(false); - layout->addWidget(m_createBtn); - - QScrollArea* scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - - m_buttonsContainer = new QWidget(scrollArea); - m_buttonsLayout = new QVBoxLayout(m_buttonsContainer); - m_buttonsLayout->setContentsMargins(0, 0, 0, 0); - m_buttonsLayout->setSpacing(4); - m_buttonsLayout->addStretch(); - - scrollArea->setWidget(m_buttonsContainer); - layout->addWidget(scrollArea, 1); - - connect(m_createBtn, &QPushButton::clicked, this, &BlueprintPanel::onCreateClicked); - - loadFromDisk(); - rebuildButtons(); - - registerForEvents(); -} - -BlueprintPanel::~BlueprintPanel() -{ - saveToDisk(); - unregisterForEvents(); -} - -void BlueprintPanel::onSelectionChanged(const std::vector& ids) -{ - m_selectedBuildingIds = ids; - refreshButtonStates(); -} - -void BlueprintPanel::handleEvent(std::shared_ptr /*event*/) -{ - refreshButtonStates(); -} - -void BlueprintPanel::clearActiveBlueprintButton() -{ - if (m_activeIndex.has_value() && *m_activeIndex < static_cast(m_blueprintButtons.size())) - { - m_blueprintButtons[static_cast(*m_activeIndex)]->setChecked(false); - } - m_activeIndex = std::nullopt; - refreshButtonStates(); -} - -void BlueprintPanel::onCreateClicked() -{ - if (m_selectedBuildingIds.empty()) { return; } - - Blueprint bp = createBlueprintFromSelection(); - if (bp.buildings.empty()) { return; } - - bool ok = false; - const QString name = QInputDialog::getText( - this, tr("Create Blueprint"), tr("Blueprint name:"), QLineEdit::Normal, QString(), &ok); - if (!ok || name.trimmed().isEmpty()) { return; } - - bp.name = name.trimmed(); - m_blueprints.push_back(bp); - rebuildButtons(); -} - -void BlueprintPanel::onDeleteBlueprintClicked(int index) -{ - if (m_activeIndex == index) - { - m_activeIndex = std::nullopt; - EventManager::getInstance()->sendEventImmediately( - std::make_shared()); - } - else if (m_activeIndex.has_value() && *m_activeIndex > index) - { - --*m_activeIndex; - } - m_blueprints.erase(m_blueprints.begin() + index); - rebuildButtons(); -} - -void BlueprintPanel::onBlueprintButtonClicked(int index) -{ - if (index < 0 || index >= static_cast(m_blueprints.size())) { return; } - - if (m_activeIndex == index) - { - clearActiveBlueprintButton(); - EventManager::getInstance()->sendEventImmediately( - std::make_shared()); - return; - } - - if (m_activeIndex.has_value() && *m_activeIndex < static_cast(m_blueprintButtons.size())) - { - m_blueprintButtons[static_cast(*m_activeIndex)]->setChecked(false); - } - - m_activeIndex = index; - m_blueprintButtons[static_cast(index)]->setChecked(true); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_blueprints[static_cast(index)])); -} - -Blueprint BlueprintPanel::createBlueprintFromSelection() const -{ - // Capture is shared, testable logic in lib/sim: it resolves each selected id as an - // operational building or a construction site alike (REQ-UI-BLUEPRINT-CREATE, - // REQ-UI-BLUEPRINT-STORAGE). - return captureBlueprintFromSelection(*m_sim, m_selectedBuildingIds); -} - -int BlueprintPanel::computeBlueprintCost(const Blueprint& bp) const -{ - int total = 0; - for (const BlueprintBuilding& bb : bp.buildings) - { - const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type); - if (def) { total += def->cost; } - } - return total; -} - -void BlueprintPanel::rebuildButtons() -{ - while (m_buttonsLayout->count() > 1) - { - QLayoutItem* item = m_buttonsLayout->takeAt(0); - if (item->widget()) { delete item->widget(); } - delete item; - } - m_blueprintButtons.clear(); - - for (int i = 0; i < static_cast(m_blueprints.size()); ++i) - { - const Blueprint& bp = m_blueprints[static_cast(i)]; - const int cost = computeBlueprintCost(bp); - const QString label = bp.name + "\n" + tr("%1 Building Blocks").arg(cost); - - QWidget* row = new QWidget(m_buttonsContainer); - QHBoxLayout* rowLayout = new QHBoxLayout(row); - rowLayout->setContentsMargins(0, 0, 0, 0); - rowLayout->setSpacing(4); - - QPushButton* btn = new QPushButton(label, row); - btn->setCheckable(true); - btn->setFixedHeight(48); - - QPushButton* delBtn = new QPushButton("\xc3\x97", row); - delBtn->setFixedWidth(28); - delBtn->setFixedHeight(48); - - rowLayout->addWidget(btn, 1); - rowLayout->addWidget(delBtn, 0); - - m_buttonsLayout->insertWidget(i, row); - - const int capturedIndex = i; - connect(btn, &QPushButton::clicked, this, [this, capturedIndex]() { - onBlueprintButtonClicked(capturedIndex); - }); - connect(delBtn, &QPushButton::clicked, this, [this, capturedIndex]() { - onDeleteBlueprintClicked(capturedIndex); - }); - - m_blueprintButtons.push_back(btn); - } - - refreshButtonStates(); -} - -void BlueprintPanel::saveToDisk() const -{ - // Persist on shutdown; write errors are silently ignored (REQ-UI-BLUEPRINT-SAVE). - const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml"; - QFile file(path); - if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return; } - try - { - const std::string content = BlueprintSerializer::serialize(m_blueprints); - file.write(QByteArray::fromStdString(content)); - } - catch (...) {} -} - -void BlueprintPanel::loadFromDisk() -{ - // Load at startup (REQ-UI-BLUEPRINT-LOAD). Missing file: start empty, no error. - const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml"; - QFile file(path); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return; } - try - { - m_blueprints = BlueprintSerializer::deserialize(file.readAll().toStdString()); - } - catch (const std::exception& e) - { - QMessageBox::critical(this, tr("Load Failed"), - tr("Failed to load blueprints:\n%1").arg(e.what())); - m_blueprints.clear(); - } -} - -void BlueprintPanel::refreshButtonStates() -{ - // A construction site counts the same as an operational building (REQ-UI-BLUEPRINT-CREATE). - m_createBtn->setEnabled(selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds)); - - const int blocks = m_sim->getBuildingBlocksStock(); - - for (int i = 0; i < static_cast(m_blueprintButtons.size()); ++i) - { - const int cost = computeBlueprintCost(m_blueprints[static_cast(i)]); - const bool canAfford = blocks >= cost; - m_blueprintButtons[static_cast(i)]->setEnabled( - canAfford || m_activeIndex == i); - } -} - -void BlueprintPanel::handleEvent(std::shared_ptr event) -{ - onSelectionChanged(event->ids); -} - -void BlueprintPanel::handleEvent(std::shared_ptr /*event*/) -{ - clearActiveBlueprintButton(); -} - -void BlueprintPanel::handleEvent(std::shared_ptr /*event*/) -{ - // Temporary blueprint (REQ-UI-BLUEPRINT-TEMP): build from the current selection and - // enter placement mode without adding it to the list or persisting it. If nothing - // player-placeable is selected, do nothing. - Blueprint bp = createBlueprintFromSelection(); - if (bp.buildings.empty()) { return; } - - // No saved blueprint is active while a temporary one is being placed. - clearActiveBlueprintButton(); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(std::move(bp))); -} diff --git a/src/ui/BlueprintPanel.h b/src/ui/BlueprintPanel.h deleted file mode 100644 index ae4dd9c..0000000 --- a/src/ui/BlueprintPanel.h +++ /dev/null @@ -1,67 +0,0 @@ -#pragma once - -#include -#include - -#include - -#include "Blueprint.h" -#include "BlueprintModeExitedEvent.h" -#include "BuildingBlocksChangedEvent.h" -#include "BuildingId.h" -#include "EventHandler.h" -#include "GameConfig.h" -#include "SelectionChangedEvent.h" -#include "TemporaryBlueprintRequestedEvent.h" -#include "Tick.h" - -class Simulation; -class QPushButton; -class QScrollArea; -class QVBoxLayout; - -class BlueprintPanel : public QWidget, - public CombinedEventHandler -{ - Q_OBJECT - -public: - BlueprintPanel(Simulation* sim, const GameConfig* config, QWidget* parent = nullptr); - ~BlueprintPanel() override; - -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; - -private slots: - void onCreateClicked(); - void onDeleteBlueprintClicked(int index); - void onBlueprintButtonClicked(int index); - -private: - void onSelectionChanged(const std::vector& ids); - void clearActiveBlueprintButton(); - Blueprint createBlueprintFromSelection() const; - int computeBlueprintCost(const Blueprint& bp) const; - void rebuildButtons(); - void refreshButtonStates(); - void loadFromDisk(); - void saveToDisk() const; - - // The simulation is the single source of truth for the block stock; the - // change event is only a refresh signal. - Simulation* m_sim; - const GameConfig* m_config; - std::vector m_selectedBuildingIds; - std::optional m_activeIndex; // nullopt = no blueprint selected - std::vector m_blueprints; - std::vector m_blueprintButtons; - QPushButton* m_createBtn; - QWidget* m_buttonsContainer; - QVBoxLayout* m_buttonsLayout; -}; diff --git a/src/ui/BlueprintSelectionDialog.cpp b/src/ui/BlueprintSelectionDialog.cpp new file mode 100644 index 0000000..fd9e288 --- /dev/null +++ b/src/ui/BlueprintSelectionDialog.cpp @@ -0,0 +1,333 @@ +#include "BlueprintSelectionDialog.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Blueprint.h" +#include "BlueprintLibrary.h" +#include "IconCaption.h" +#include "ItemIconCache.h" + +namespace +{ + // Fixed at two, per REQ-UI-BLUEPRINT-DIALOG; the grid scrolls rather than reflowing. + const int kGridColumnCount = 2; + + // Card geometry. Every card is the same size so the grid stays even + // (REQ-UI-BLUEPRINT-CARD); the width is chosen to fit a name and a short contents + // line, and the height follows from three text rows. + const int kCardWidthPx = 220; + const int kCardPaddingPx = 10; + const int kCardRowGapPx = 4; + + // Side length of the per-card delete icon and of the dialog's close button. + const int kSmallButtonSizePx = 22; + + // Spacing between cards, and the dialog's own content margin. + const int kSpacingPx = 8; + + // Card rows visible before the grid scrolls. The half row is deliberate: a cut-off + // card reads as "there is more below" at a glance. + const double kVisibleRowCount = 2.5; + + // U+00D7 MULTIPLICATION SIGN, the close and delete glyph. Written as a code point + // because the sources are not guaranteed to be read as UTF-8 by every compiler. + const QChar kCrossGlyph(0x00D7); + + // Inner size of a card's composed face: the name row, the contents row, and the + // cost row, plus the gap above the cost. + QSize getCardFaceSize(const QFont& font) + { + const int rowHeight = QFontMetrics(font).height(); + return QSize(kCardWidthPx - 2 * kCardPaddingPx, rowHeight * 3 + kCardRowGapPx); + } + + QSize getCardSize(const QFont& font) + { + const QSize faceSize = getCardFaceSize(font); + return QSize(faceSize.width() + 2 * kCardPaddingPx, + faceSize.height() + 2 * kCardPaddingPx); + } + + // One card face: the blueprint name, its contents line below in a dimmed color, + // and the cost with its block icon at the bottom (REQ-UI-BLUEPRINT-CARD). The three + // are composed into a single pixmap because a QPushButton holds only one icon -- + // the same device the build button faces use. + QPixmap composeCardFace(const QString& name, const QString& contents, + const QPixmap& cost, const QFont& font, + const QColor& textColor, const QColor& dimColor, + const QSize& faceSize) + { + const QFontMetrics metrics(font); + const int rowHeight = metrics.height(); + + const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0; + QPixmap face(static_cast(faceSize.width() * dpr), + static_cast(faceSize.height() * dpr)); + face.setDevicePixelRatio(dpr); + face.fill(Qt::transparent); + + QPainter painter(&face); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.setFont(font); + + // Both text rows are elided rather than wrapped or shrunk, so a long name or a + // many-typed blueprint cannot change a card's height (REQ-UI-BLUEPRINT-CARD). + painter.setPen(textColor); + painter.drawText(QRect(0, 0, faceSize.width(), rowHeight), + Qt::AlignLeft | Qt::AlignVCenter, + metrics.elidedText(name, Qt::ElideRight, faceSize.width())); + + painter.setPen(dimColor); + painter.drawText(QRect(0, rowHeight, faceSize.width(), rowHeight), + Qt::AlignLeft | Qt::AlignVCenter, + metrics.elidedText(contents, Qt::ElideRight, faceSize.width())); + + const QSize costSize = getLogicalSize(cost); + painter.drawPixmap(0, faceSize.height() - costSize.height(), cost); + return face; + } + + // A composed card face and the size to show it at; the button needs both, and only + // the composer knows the size it arrived at. + struct CardFace { QIcon icon; QSize size; }; + + // The two-mode face of one card. The modes differ only in the text color, so an + // unaffordable card greys itself when Qt swaps the pixmap on the disabled button + // (REQ-UI-BLUEPRINT-CARD, consistent with REQ-UI-BUILD-DISABLED). + CardFace buildCardFace(const QString& name, const QString& contents, int cost, + const QPixmap& blockIcon, const QFont& font, + const QPalette& palette) + { + const QSize faceSize = getCardFaceSize(font); + const QColor dimColor = palette.color(QPalette::Disabled, QPalette::ButtonText); + + CardFace result; + for (QIcon::Mode mode : { QIcon::Normal, QIcon::Disabled }) + { + const QColor textColor = palette.color( + (mode == QIcon::Normal) ? QPalette::Active : QPalette::Disabled, + QPalette::ButtonText); + // The cost reads exactly like the header stock and the build button costs: + // the number, then the block icon in place of a trailing "Blocks" + // (REQ-UI-BLOCKS-ICON). A null icon leaves the bare number. + const QPixmap costPixmap = + renderCaptionWithIcon(QString::number(cost), blockIcon, font, textColor); + const QPixmap face = composeCardFace(name, contents, costPixmap, font, + textColor, dimColor, faceSize); + result.icon.addPixmap(face, mode); + result.size = result.size.expandedTo(getLogicalSize(face)); + } + return result; + } +} + +BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library, + ItemIconCache* itemIcons, + QWidget* parent) + : QDialog(parent) + , m_library(library) + , m_itemIcons(itemIcons) +{ + setWindowTitle(tr("Blueprints")); + setModal(true); + // Frameless: the dialog draws its own header row, so an OS title bar would only + // repeat it (REQ-UI-BLUEPRINT-DIALOG). Square corners rather than rounded ones -- + // rounding a top-level window needs a translucent background, which is unreliable + // on Windows. The border matches the build bar and the side panel. + setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint); + setAttribute(Qt::WA_StyledBackground, true); + setStyleSheet(QStringLiteral( + "BlueprintSelectionDialog { background-color: palette(window);" + " border: 1px solid palette(mid); }")); + + QVBoxLayout* mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(kSpacingPx, kSpacingPx, kSpacingPx, kSpacingPx); + mainLayout->setSpacing(kSpacingPx); + + QHBoxLayout* headerLayout = new QHBoxLayout(); + headerLayout->setSpacing(kSpacingPx); + + QLabel* titleLabel = new QLabel(tr("Blueprints"), this); + QFont headerFont = titleLabel->font(); + headerFont.setBold(true); + titleLabel->setFont(headerFont); + headerLayout->addWidget(titleLabel); + + // Dimmed and bold, the treatment the build button hotkey badges use, so the + // shortcut reads as a reminder rather than a second title (REQ-UI-BUILD-COST). + QLabel* hotkeyBadge = new QLabel(tr("Ctrl+V"), this); + hotkeyBadge->setFont(headerFont); + QPalette badgePalette = hotkeyBadge->palette(); + badgePalette.setColor(hotkeyBadge->foregroundRole(), + palette().color(QPalette::Disabled, QPalette::WindowText)); + hotkeyBadge->setPalette(badgePalette); + headerLayout->addWidget(hotkeyBadge); + + headerLayout->addStretch(); + + QPushButton* closeButton = new QPushButton(QString(kCrossGlyph), this); + closeButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx); + closeButton->setToolTip(tr("Close")); + connect(closeButton, &QPushButton::clicked, this, &QDialog::reject); + headerLayout->addWidget(closeButton); + + mainLayout->addLayout(headerLayout); + + QScrollArea* scrollArea = new QScrollArea(this); + scrollArea->setWidgetResizable(true); + scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea->setFrameShape(QFrame::NoFrame); + + m_gridContainer = new QWidget(scrollArea); + m_grid = new QGridLayout(m_gridContainer); + m_grid->setContentsMargins(0, 0, 0, 0); + m_grid->setSpacing(kSpacingPx); + m_grid->setAlignment(Qt::AlignTop | Qt::AlignLeft); + scrollArea->setWidget(m_gridContainer); + mainLayout->addWidget(scrollArea, 1); + + rebuildGrid(); + + // Fixed size (REQ-UI-BLUEPRINT-DIALOG), sized to show kVisibleRowCount card rows. + const QSize cardSize = getCardSize(font()); + const int gridWidth = kGridColumnCount * cardSize.width() + + (kGridColumnCount - 1) * kSpacingPx; + const int gridHeight = static_cast( + cardSize.height() * kVisibleRowCount + kSpacingPx * (kVisibleRowCount - 1.0)); + setFixedSize(gridWidth + style()->pixelMetric(QStyle::PM_ScrollBarExtent) + + 2 * kSpacingPx, + gridHeight + kSmallButtonSizePx + 3 * kSpacingPx); + + // A frameless dialog does not get Qt's automatic centering on its parent. + if (parent) + { + const QRect parentRect = parent->window()->geometry(); + move(parentRect.center() - QPoint(width() / 2, height() / 2)); + } +} + +std::optional BlueprintSelectionDialog::getChosenIndex() const +{ + return m_chosenIndex; +} + +void BlueprintSelectionDialog::rebuildGrid() +{ + // deleteLater, not delete: this runs from a delete button's own clicked signal, and + // destroying that button inside its emission would leave Qt holding a dangling + // sender. hide() makes the removal immediate all the same. + while (QLayoutItem* item = m_grid->takeAt(0)) + { + if (item->widget()) + { + item->widget()->hide(); + item->widget()->deleteLater(); + } + delete item; + } + + const std::vector& blueprints = m_library->getBlueprints(); + if (blueprints.empty()) + { + QLabel* emptyLabel = new QLabel( + tr("No blueprints yet.\n\nSelect one or more buildings and press Ctrl+C " + "to save them as a blueprint."), + m_gridContainer); + emptyLabel->setAlignment(Qt::AlignCenter); + emptyLabel->setWordWrap(true); + m_grid->addWidget(emptyLabel, 0, 0, 1, kGridColumnCount); + return; + } + + // Block icon shown to the right of each cost (REQ-UI-BLUEPRINT-CARD); null when no + // building_block icon exists, in which case the cost is the bare number. + const QPixmap blockIcon = m_itemIcons->hasIcon(kBlockItemId) + ? m_itemIcons->getPixmap(kBlockItemId, QFontMetrics(font()).height()) + : QPixmap(); + + const QSize cardSize = getCardSize(font()); + + for (int i = 0; i < static_cast(blueprints.size()); ++i) + { + const Blueprint& blueprint = blueprints[static_cast(i)]; + const CardFace face = buildCardFace( + blueprint.name, m_library->getContentsSummary(i), m_library->getCost(i), + blockIcon, font(), palette()); + + QWidget* card = new QWidget(m_gridContainer); + card->setFixedSize(cardSize); + + QVBoxLayout* cardLayout = new QVBoxLayout(card); + cardLayout->setContentsMargins(0, 0, 0, 0); + + QPushButton* body = new QPushButton(card); + body->setIcon(face.icon); + body->setIconSize(face.size); + // An unaffordable card is greyed and does not respond, but its delete icon + // stays live (REQ-UI-BLUEPRINT-CARD). + body->setEnabled(m_library->getCanAfford(i)); + cardLayout->addWidget(body); + + // A sibling of the body rather than one of its children, and outside the + // layout: Qt disables a widget's children along with it, and the delete icon + // must stay enabled on an unaffordable card (REQ-UI-BLUEPRINT-DELETE). + QPushButton* deleteButton = new QPushButton(QString(kCrossGlyph), card); + deleteButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx); + deleteButton->setToolTip(tr("Delete blueprint")); + deleteButton->move(cardSize.width() - kSmallButtonSizePx - kCardPaddingPx / 2, + cardSize.height() - kSmallButtonSizePx - kCardPaddingPx / 2); + deleteButton->raise(); + + m_grid->addWidget(card, i / kGridColumnCount, i % kGridColumnCount); + + const int index = i; + connect(body, &QPushButton::clicked, this, [this, index]() + { + onCardClicked(index); + }); + connect(deleteButton, &QPushButton::clicked, this, [this, index]() + { + onDeleteClicked(index); + }); + } +} + +void BlueprintSelectionDialog::onCardClicked(int index) +{ + // Placement mode is entered by the caller once the dialog has closed + // (REQ-UI-BLUEPRINT-CARD). + m_chosenIndex = index; + accept(); +} + +void BlueprintSelectionDialog::onDeleteClicked(int index) +{ + // No confirmation prompt, and the dialog stays open (REQ-UI-BLUEPRINT-DELETE). + m_library->remove(index); + rebuildGrid(); +} diff --git a/src/ui/BlueprintSelectionDialog.h b/src/ui/BlueprintSelectionDialog.h new file mode 100644 index 0000000..04528fd --- /dev/null +++ b/src/ui/BlueprintSelectionDialog.h @@ -0,0 +1,42 @@ +#pragma once + +#include + +#include + +class BlueprintLibrary; +class ItemIconCache; +class QGridLayout; +class QWidget; + +// The blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG): a frameless modal panel +// showing every saved blueprint as a card in a scrolling two-column grid. The caller +// pauses the game and raises the dim overlay while it is open. +// +// Clicking a card accepts the dialog and reports that blueprint's index; the caller +// enters placement mode afterwards, so the dialog is already closed by then +// (REQ-UI-BLUEPRINT-CARD). Deleting acts on the library immediately and leaves the +// dialog open (REQ-UI-BLUEPRINT-DELETE). Escape and the close button dismiss it with +// no other effect. +class BlueprintSelectionDialog : public QDialog +{ + Q_OBJECT + +public: + // Neither the library nor the icon cache is owned; both outlive the dialog. + BlueprintSelectionDialog(BlueprintLibrary* library, ItemIconCache* itemIcons, + QWidget* parent = nullptr); + + std::optional getChosenIndex() const; + +private: + void rebuildGrid(); + void onCardClicked(int index); + void onDeleteClicked(int index); + + BlueprintLibrary* m_library; + ItemIconCache* m_itemIcons; + QWidget* m_gridContainer; + QGridLayout* m_grid; + std::optional m_chosenIndex; // nullopt = dismissed without picking +}; diff --git a/src/ui/BuildButtonBar.cpp b/src/ui/BuildButtonBar.cpp index 22e646b..7cf2ec3 100644 --- a/src/ui/BuildButtonBar.cpp +++ b/src/ui/BuildButtonBar.cpp @@ -96,16 +96,6 @@ namespace return result; } - // A pixmap's size in device-independent pixels. The pixmaps composed here are - // rasterized at the device pixel ratio, so their raw size is not their layout size. - QSize getLogicalSize(const QPixmap& pixmap) - { - if (pixmap.isNull()) { return QSize(0, 0); } - const qreal dpr = pixmap.devicePixelRatio(); - return QSize(static_cast(pixmap.width() / dpr), - static_cast(pixmap.height() / dpr)); - } - // One button face: the hotkey badge in the top-left corner, the chip icon centered // below it, and the caption — the cost, or the Deconstruct name — centered at the // bottom (REQ-UI-BUILD-COST). The three are composed into a single pixmap because diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 284da17..b553777 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -13,7 +13,8 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h - ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h + ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h + ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h @@ -38,7 +39,8 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp diff --git a/src/ui/IconCaption.cpp b/src/ui/IconCaption.cpp index f703cd6..9a1436c 100644 --- a/src/ui/IconCaption.cpp +++ b/src/ui/IconCaption.cpp @@ -46,3 +46,11 @@ QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon, return pixmap; } + +QSize getLogicalSize(const QPixmap& pixmap) +{ + if (pixmap.isNull()) { return QSize(0, 0); } + const qreal dpr = pixmap.devicePixelRatio(); + return QSize(static_cast(pixmap.width() / dpr), + static_cast(pixmap.height() / dpr)); +} diff --git a/src/ui/IconCaption.h b/src/ui/IconCaption.h index 7750394..c9bca08 100644 --- a/src/ui/IconCaption.h +++ b/src/ui/IconCaption.h @@ -3,6 +3,7 @@ #include #include #include +#include #include // Renders `text` followed by `icon` to its right, vertically centered, onto a @@ -15,6 +16,11 @@ QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon, const QFont& font, const QColor& textColor); +// A pixmap's size in device-independent pixels. The pixmaps composed by +// renderCaptionWithIcon and its callers are rasterized at the device pixel ratio, so +// their raw size is not the size to lay them out at. +QSize getLogicalSize(const QPixmap& pixmap); + // Item id of the building blocks resource, whose icon stands in for the "Blocks" // word wherever a cost or stock is captioned (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST, // REQ-UI-EXPAND-BUTTON). It lives next to the caption helper because every caller of diff --git a/src/ui/InputMapper.cpp b/src/ui/InputMapper.cpp index dfbbeb6..c830e68 100644 --- a/src/ui/InputMapper.cpp +++ b/src/ui/InputMapper.cpp @@ -5,6 +5,8 @@ #include +#include "BlueprintSaveRequestedEvent.h" +#include "BlueprintSelectionRequestedEvent.h" #include "BuildHotkeyPressedEvent.h" #include "BuildingType.h" #include "DebugDrawToggleRequestedEvent.h" @@ -97,6 +99,27 @@ 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/T and must not fire on a Ctrl chord. 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()); + return true; + case Qt::Key_V: + EventManager::getInstance()->sendEventImmediately( + std::make_shared()); + return true; + default: + break; + } + } + switch (event->key()) { case Qt::Key_A: @@ -131,7 +154,7 @@ bool InputMapper::handleKeyPress(QKeyEvent* event) return true; case Qt::Key_T: // Request a temporary blueprint from the current selection (REQ-UI-BLUEPRINT-TEMP). - // The BlueprintPanel owns the selection and blueprint-capture logic; it decides + // 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()); diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 80c318f..b98a7e2 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -9,12 +9,15 @@ #include #include #include +#include +#include #include #include #include #include -#include "BlueprintPanel.h" +#include "BlueprintLibrary.h" +#include "BlueprintSelectionDialog.h" #include "BuildButtonBar.h" #include "BuildingSystem.h" #include "Command.h" @@ -71,31 +74,29 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, m_buildButtonBar = new BuildButtonBar(sim, &sim->getConfig(), iconDir, m_itemIcons.get(), this); + // The blueprints have no widget of their own: they are saved with Ctrl+C and picked + // from a modal dialog (REQ-UI-BLUEPRINT-DIALOG), both driven from this window + // 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(sim, &sim->getConfig(), this); + m_sidePanel = new QWidget(this); QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel); sideLayout->setContentsMargins(1, 1, 1, 1); sideLayout->setSpacing(1); - m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel); - m_blueprintPanel = new BlueprintPanel(sim, &sim->getConfig(), m_sidePanel); - - // Equal stretch gives the two panels half the column height each + // The selected building panel is the column's only panel and fills its height // (REQ-UI-PANEL-COLUMN). + m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel); sideLayout->addWidget(m_selectedBuildingPanel, 1); - sideLayout->addWidget(m_blueprintPanel, 1); - // Draw a thin border around each of the two side-panel sections. The class - // scoped selectors keep the border on the panels themselves rather than - // cascading onto their child widgets; WA_StyledBackground lets the plain - // QWidget subclasses honor the stylesheet box (border/background). - for (QWidget* panel : { static_cast(m_selectedBuildingPanel), - static_cast(m_blueprintPanel) }) - { - panel->setAttribute(Qt::WA_StyledBackground, true); - } + // Draw a thin border around the side panel section. The class scoped selector keeps + // the border on the panel itself rather than cascading onto its child widgets; + // WA_StyledBackground lets the plain QWidget subclass honor the stylesheet box + // (border/background). + m_selectedBuildingPanel->setAttribute(Qt::WA_StyledBackground, true); m_sidePanel->setStyleSheet(QStringLiteral( - "SelectedBuildingPanel, BlueprintPanel {" - " border: 1px solid palette(mid); }")); + "SelectedBuildingPanel { border: 1px solid palette(mid); }")); // 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). @@ -369,6 +370,47 @@ void MainWindow::handleEvent(std::shared_ptr /*event*/) +{ + // Ctrl+C has effect only when something player-placeable is selected; otherwise no + // dialog opens at all (REQ-UI-BLUEPRINT-CREATE). + if (!m_blueprintLibrary->getCanCaptureSelection()) { return; } + + // Both scopes are held across the naming dialog and the selection dialog it hands + // over to, so the dim never blinks off and the simulation is not resumed in between + // (REQ-UI-MODAL-DIM). + ModalPauseScope pause(*m_gameWorldView); + ModalDimScope dim(*m_dimOverlay); + + bool ok = false; + const QString name = QInputDialog::getText( + this, tr("Create Blueprint"), tr("Blueprint name:"), QLineEdit::Normal, + QString(), &ok); + // Cancel, Escape, or an empty name: no blueprint, and no selection dialog. + if (!ok || name.trimmed().isEmpty()) { return; } + + m_blueprintLibrary->saveSelectionAs(name.trimmed()); + showBlueprintSelectionDialog(); +} + +void MainWindow::handleEvent(std::shared_ptr /*event*/) +{ + ModalPauseScope pause(*m_gameWorldView); + ModalDimScope dim(*m_dimOverlay); + showBlueprintSelectionDialog(); +} + +void MainWindow::showBlueprintSelectionDialog() +{ + BlueprintSelectionDialog dialog(m_blueprintLibrary.get(), m_itemIcons.get(), this); + if (dialog.exec() == QDialog::Accepted && dialog.getChosenIndex().has_value()) + { + // Entered after the dialog has closed, which is the order REQ-UI-BLUEPRINT-CARD + // describes: clicking a card closes the dialog and enters placement mode. + m_blueprintLibrary->beginPlacement(*dialog.getChosenIndex()); + } +} + void MainWindow::handleEvent(std::shared_ptr /*event*/) { const Tick tick = m_sim->getCurrentTick(); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 0fa9fd4..cb10a4c 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -7,6 +7,8 @@ #include +#include "BlueprintSaveRequestedEvent.h" +#include "BlueprintSelectionRequestedEvent.h" #include "BuildingId.h" #include "EscapeMenuRequestedEvent.h" #include "EventHandler.h" @@ -28,7 +30,7 @@ class GameWorldView; class HeaderBar; class SelectedBuildingPanel; class BuildButtonBar; -class BlueprintPanel; +class BlueprintLibrary; class ItemIconCache; class QCloseEvent; class QResizeEvent; @@ -39,7 +41,9 @@ class MainWindow : public QWidget, WinEvent, EscapeMenuRequestedEvent, LayoutDialogRequestedEvent, - RecipeSelectionRequestedEvent> + RecipeSelectionRequestedEvent, + BlueprintSaveRequestedEvent, + BlueprintSelectionRequestedEvent> { Q_OBJECT @@ -59,6 +63,8 @@ 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; // Reloads the game config and visuals.toml from disk (REQ-CFG-RELOAD), shared // by every restart path. On success the reloaded visuals are applied to this @@ -71,6 +77,12 @@ private: void openShipLayoutDialog(BuildingId shipyardId, const std::string& schematicId, const ShipLayoutConfig& currentLayout); + + // Runs the blueprint selection dialog and enters placement mode for whatever the + // player picked (REQ-UI-BLUEPRINT-DIALOG). Holds no pause or dim scope of its own: + // both callers already hold theirs, which is what keeps the dim continuous when a + // confirmed save hands straight over to this dialog (REQ-UI-MODAL-DIM). + void showBlueprintSelectionDialog(); void layoutPanels(); private: @@ -84,7 +96,9 @@ private: HeaderBar* m_headerBar; SelectedBuildingPanel* m_selectedBuildingPanel; BuildButtonBar* m_buildButtonBar; - BlueprintPanel* m_blueprintPanel; + // 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). + std::unique_ptr m_blueprintLibrary; QWidget* m_sidePanel; ModalDimOverlay* m_dimOverlay = nullptr;