From d713257fb5435e70f4ea55670bb4400275e3d82e Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Tue, 4 Aug 2026 15:47:22 +0200 Subject: [PATCH] move the field selection out of SelectedBuildingPanel The two selection categories used to arbitrate ownership of the panel by poking each other's widgets: buildFieldSelection() called clearContent() and buildEmpty(), buildEmpty() hid the four entity widgets, and hideAllWidgets() hid the scrap label. Splitting the halves apart without naming an arbiter would only have spread that across a class boundary. SelectedBuildingPanel is now the sole arbiter. It still receives all three selection events, forwards the two field ones to the embedded FieldSelectionPanel, and drops its own selection and content as soon as the field panel reports a selection (yieldToFieldSelection), mirroring what onSelectionChanged() already did in the other direction. The field panel decides only what to render and whether it is visible at all. Dropping the field branch of refreshSelectionDisplay() is behaviour preserving: whenever the field category owns the panel, m_singleBuildingId is null, so the building refresh returns immediately anyway. clearContent() and buildEmpty() became identical once the cross-half hiding was gone, so only buildEmpty() remains. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG --- src/ui/SelectedBuildingPanel.cpp | 391 +++---------------------------- src/ui/SelectedBuildingPanel.h | 51 ++-- 2 files changed, 44 insertions(+), 398 deletions(-) diff --git a/src/ui/SelectedBuildingPanel.cpp b/src/ui/SelectedBuildingPanel.cpp index 5c6f79e..84bc502 100644 --- a/src/ui/SelectedBuildingPanel.cpp +++ b/src/ui/SelectedBuildingPanel.cpp @@ -9,26 +9,14 @@ #include #include #include -#include #include #include "BeltSystem.h" #include "Command.h" #include "CommandRequestedEvent.h" -#include "DisplayName.h" -#include "DynamicBodyComponent.h" -#include "EntityAdmin.h" #include "EntitySelectionChangedEvent.h" #include "EventManager.h" -#include "FactionComponent.h" -#include "HealthComponent.h" -#include "ModuleOwnerComponent.h" -#include "SelectedBehaviorComponent.h" -#include "ShipIdentityComponent.h" -#include "ShipStatsCalculator.h" -#include "ShipStatsPanel.h" -#include "ThreatCostCalculator.h" -#include "StationBodyComponent.h" +#include "FieldSelectionPanel.h" #include "TickAdvancedEvent.h" #include "Building.h" #include "BuildingSystem.h" @@ -40,10 +28,8 @@ #include "RecipeSelectionDialog.h" #include "RecipeSelectionRequestedEvent.h" #include "Rotation.h" -#include "DebrisSystem.h" #include "ShipLayoutPreview.h" #include "Simulation.h" -#include "WeaponComponent.h" namespace { @@ -182,30 +168,10 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim, connect(m_filterBList, &QListWidget::itemChanged, this, &SelectedBuildingPanel::onSplitterFilterChanged); - m_entityTitleLabel = new QLabel(this); - QFont titleFont = m_entityTitleLabel->font(); - titleFont.setBold(true); - m_entityTitleLabel->setFont(titleFont); - m_layout->addWidget(m_entityTitleLabel); - m_entityTitleLabel->hide(); - - m_entityStatsPanel = new ShipStatsPanel(config, this); - m_layout->addWidget(m_entityStatsPanel); - m_entityStatsPanel->hide(); - - m_stationStatsLabel = new QLabel(this); - m_stationStatsLabel->setWordWrap(true); - m_layout->addWidget(m_stationStatsLabel); - m_stationStatsLabel->hide(); - - m_entitySummaryLabel = new QLabel(this); - m_entitySummaryLabel->setWordWrap(true); - m_layout->addWidget(m_entitySummaryLabel); - m_entitySummaryLabel->hide(); - - m_scrapLabel = new QLabel(this); - m_layout->addWidget(m_scrapLabel); - m_scrapLabel->hide(); + // The field selection renders below the building content and hides itself while + // nothing field-side is selected, so it costs no space then. + m_fieldSelectionPanel = new FieldSelectionPanel(sim, config, this); + m_layout->addWidget(m_fieldSelectionPanel); buildEmpty(); @@ -224,13 +190,21 @@ void SelectedBuildingPanel::onSelectionChanged(const std::vector& id { // A building selection is exclusive: it supersedes any field selection — // actors and scrap (REQ-UI-SELECTION-CATEGORIES). - clearEntityDisplay(); - m_selectedDebris.clear(); - m_scrapLabel->hide(); + m_fieldSelectionPanel->clearSelection(); } rebuild(); } +void SelectedBuildingPanel::yieldToFieldSelection() +{ + // The mirror image of onSelectionChanged(): a field selection — actors, debris, or + // both — supersedes any building selection (REQ-UI-SELECTION-CATEGORIES). An empty + // field selection changes nothing here: the building content, if any, keeps the panel. + if (!m_fieldSelectionPanel->hasSelection()) { return; } + m_selectedBuildingIds.clear(); + buildEmpty(); +} + void SelectedBuildingPanel::rebuild() { if (m_selectedBuildingIds.empty()) @@ -259,22 +233,14 @@ void SelectedBuildingPanel::hideAllWidgets() m_filterBLabel->hide(); m_filterBList->hide(); m_buffersLabel->hide(); - m_scrapLabel->hide(); -} - -void SelectedBuildingPanel::clearContent() -{ - m_singleBuildingId = std::nullopt; - hideAllWidgets(); } void SelectedBuildingPanel::buildEmpty() { - clearContent(); - m_entityTitleLabel->hide(); - m_entityStatsPanel->hide(); - m_stationStatsLabel->hide(); - m_entitySummaryLabel->hide(); + // Shows nothing for the building category — either because nothing is selected or + // because the field category has taken the panel over. + m_singleBuildingId = std::nullopt; + hideAllWidgets(); } void SelectedBuildingPanel::buildSingle(BuildingId id) @@ -646,27 +612,9 @@ void SelectedBuildingPanel::handleEvent( void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason) { - if (!m_selectedEntities.empty() || !m_selectedDebris.empty()) - { - // Field selection. Keep the live values current: the single-actor stats panel, - // the single-debris stats panel (whose Scrap row shrinks as it is collected), or - // the count summary (whose Scrap line shrinks likewise) — matching the layout - // chosen by buildFieldSelection() (REQ-UI-SHIP-STATS-PANEL, REQ-UI-DEBRIS-PANEL). - if (m_selectedEntities.size() == 1 && m_selectedDebris.empty()) - { - refreshEntityStats(); - } - else if (m_selectedEntities.empty() && m_selectedDebris.size() == 1) - { - buildDebrisSingle(); - } - else - { - buildEntitySummary(); - } - return; - } - + // Only a single selected building has live content to refresh. While the field + // category owns the panel there is none: yieldToFieldSelection() has cleared it, so + // this returns immediately and the field panel refreshes itself off the same events. if (!m_singleBuildingId.has_value()) { return; } const Building* b = m_sim->getBuildings().findBuilding(*m_singleBuildingId); if (b) @@ -904,259 +852,8 @@ void SelectedBuildingPanel::onClearBelt() void SelectedBuildingPanel::handleEvent(std::shared_ptr event) { - m_selectedEntities = event->entities; - if (!m_selectedEntities.empty()) - { - // A field selection supersedes any building selection (REQ-UI-SELECTION-CATEGORIES). - m_selectedBuildingIds.clear(); - } - buildFieldSelection(); -} - -void SelectedBuildingPanel::buildFieldSelection() -{ - if (m_selectedEntities.empty() && m_selectedDebris.empty()) - { - // Nothing in the field category. Fall back to empty unless buildings own the panel. - clearEntityDisplay(); - m_scrapLabel->hide(); - if (m_selectedBuildingIds.empty()) - { - buildEmpty(); - } - return; - } - - // A field selection owns the panel: drop any building content. - clearContent(); - - EntityAdmin& admin = m_sim->getAdmin(); - - // A full single-object stats panel is shown only for a lone field object: one actor - // with no debris, or one piece of debris with no actors. As soon as the selection holds - // more than one object (multiple actors, multiple debris, or actors plus debris), the - // panel switches to the compact count summary (REQ-UI-FIELD-MULTI-SELECTION). - if (m_selectedEntities.size() == 1 && m_selectedDebris.empty()) - { - m_entitySummaryLabel->hide(); - m_scrapLabel->hide(); - const entt::entity entity = m_selectedEntities.front(); - if (admin.isValid(entity) && admin.hasAll(entity)) - { - buildEntityShip(entity); - } - else if (admin.isValid(entity) && admin.hasAll(entity)) - { - buildEntityStation(entity); - } - else - { - m_entityTitleLabel->hide(); - m_entityStatsPanel->hide(); - m_stationStatsLabel->hide(); - } - return; - } - - if (m_selectedEntities.empty() && m_selectedDebris.size() == 1) - { - // Single piece of debris: a "Debris" heading plus a "Scrap" stat row, styled like - // the ship/station stats panels (REQ-UI-DEBRIS-PANEL). - m_entitySummaryLabel->hide(); - m_entityStatsPanel->hide(); - m_stationStatsLabel->hide(); - buildDebrisSingle(); - return; - } - - // More than one field object: a compact count summary. buildEntitySummary() appends the - // "Debris x N" and "Scrap x N" lines when debris is part of the selection. - m_entityTitleLabel->hide(); - m_entityStatsPanel->hide(); - m_stationStatsLabel->hide(); - m_scrapLabel->hide(); - buildEntitySummary(); -} - -void SelectedBuildingPanel::buildDebrisSingle() -{ - // "Debris" heading + a single "Scrap" stat row for the piece's remaining amount, - // mirroring the single-actor stats panels (REQ-UI-DEBRIS-PANEL). - m_entityTitleLabel->setText(tr("Debris")); - m_entityTitleLabel->show(); - m_scrapLabel->setText(tr("Scrap: %1").arg(selectedDebrisScrapTotal())); - m_scrapLabel->show(); -} - -void SelectedBuildingPanel::buildEntitySummary() -{ - EntityAdmin& admin = m_sim->getAdmin(); - - // Group actors by faction + kind + ship schematic, preserving first-seen order - // (REQ-UI-FIELD-MULTI-SELECTION). - std::vector keys; - std::map counts; - std::map labels; - - for (entt::entity entity : m_selectedEntities) - { - if (!admin.isValid(entity)) { continue; } - const bool isEnemy = admin.hasAll(entity) - && admin.get(entity).isEnemy; - - QString key; - QString label; - if (admin.hasAll(entity)) - { - const std::string& id = admin.get(entity).schematicId; - const QString name = QString::fromStdString(toDisplayName(id)); - key = (isEnemy ? QStringLiteral("ship:enemy:") : QStringLiteral("ship:player:")) - + QString::fromStdString(id); - label = isEnemy ? tr("Enemy %1").arg(name) : name; - } - else if (admin.hasAll(entity)) - { - key = isEnemy ? QStringLiteral("station:enemy") : QStringLiteral("station:player"); - label = isEnemy ? tr("Enemy Defence Station") : tr("Player Defence Station"); - } - else - { - continue; - } - - if (counts.find(key) == counts.end()) - { - keys.push_back(key); - labels[key] = label; - } - counts[key] += 1; - } - - // One " x " line per group (matching the recipe tooltip and the building - // multi-selection). No total-count header, consistent with the building panel. When - // debris is part of the selection, a "Debris x " line followed by a - // "Scrap x " line are appended into the same label so the line spacing is - // uniform (REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL). - QStringList lines; - for (const QString& key : keys) - { - lines << tr("%1 x %2").arg(labels[key]).arg(counts[key]); - } - if (!m_selectedDebris.empty()) - { - lines << tr("Debris x %1").arg(static_cast(m_selectedDebris.size())); - lines << scrapTotalText(); - } - m_entitySummaryLabel->setText(lines.join('\n')); - m_entitySummaryLabel->show(); -} - -void SelectedBuildingPanel::buildEntityShip(entt::entity entity) -{ - EntityAdmin& admin = m_sim->getAdmin(); - const ShipIdentityComponent& identity = admin.get(entity); - const HealthComponent& health = admin.get(entity); - - m_entityTitleLabel->setText(tr("Ship: %1") - .arg(QString::fromStdString(identity.schematicId))); - m_entityTitleLabel->show(); - - const ShipStats stats = buildShipStatsFromEntity(admin, entity); - m_entityStatsPanel->refreshFromLive(stats, health.hp); - m_entityStatsPanel->setBehavior( - admin.get(entity).winner); - m_entityStatsPanel->setDebugDrawEnabled(m_debugDraw); - - const ShipDef* schematicDef = - m_config->ships.findShipDef(identity.schematicId); - if (schematicDef) - { - const double threat = calculateShipThreatCost( - m_config->threatCosts, *m_config, schematicDef->id, - schematicDef->defaultModules); - m_entityStatsPanel->setThreatCost(threat); - } - - m_entityStatsPanel->show(); - - m_stationStatsLabel->hide(); -} - -void SelectedBuildingPanel::buildEntityStation(entt::entity entity) -{ - EntityAdmin& admin = m_sim->getAdmin(); - const HealthComponent& health = admin.get(entity); - - const bool isEnemy = admin.hasAll(entity) - && admin.get(entity).isEnemy; - m_entityTitleLabel->setText(isEnemy - ? tr("Enemy Defence Station") - : tr("Player Defence Station")); - m_entityTitleLabel->show(); - - float totalDps = 0.0f; - float maxRange = 0.0f; - bool hasWeapons = false; - - admin.forEach( - [&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const WeaponComponent& w) - { - if (owner.owner != entity) { return; } - hasWeapons = true; - totalDps += w.damage * w.fireRateHz; - if (w.range_tiles > maxRange) { maxRange = w.range_tiles; } - }); - - QString statsText = tr("HP: %1 / %2") - .arg(static_cast(health.hp + 0.5f)) - .arg(static_cast(health.maxHp + 0.5f)); - - if (hasWeapons) - { - statsText += tr("\nDPS: %1").arg(QString::number(static_cast(totalDps), 'f', 1)); - statsText += tr("\nRange: %1 tiles").arg(QString::number(static_cast(maxRange), 'f', 1)); - } - - m_stationStatsLabel->setText(statsText); - m_stationStatsLabel->show(); - - m_entityStatsPanel->hide(); -} - -void SelectedBuildingPanel::refreshEntityStats() -{ - // Only the single-actor stats panel needs a live refresh; the multi-actor summary is - // static counts, and GameWorldView prunes dead/despawned actors and re-emits the - // selection (REQ-UI-ENTITY-CLICK-SELECT), so the panel does not mutate it here. - if (m_selectedEntities.size() != 1) { return; } - - EntityAdmin& admin = m_sim->getAdmin(); - const entt::entity entity = m_selectedEntities.front(); - - if (!admin.isValid(entity) || !admin.hasAll(entity)) { return; } - const HealthComponent& health = admin.get(entity); - if (health.hp <= 0.0f) { return; } - - if (admin.hasAll(entity)) - { - const ShipStats stats = buildShipStatsFromEntity(admin, entity); - m_entityStatsPanel->refreshFromLive(stats, health.hp); - m_entityStatsPanel->setBehavior( - admin.get(entity).winner); - } - else if (admin.hasAll(entity)) - { - buildEntityStation(entity); - } -} - -void SelectedBuildingPanel::clearEntityDisplay() -{ - m_selectedEntities.clear(); - m_entityTitleLabel->hide(); - m_entityStatsPanel->hide(); - m_stationStatsLabel->hide(); - m_entitySummaryLabel->hide(); + m_fieldSelectionPanel->setSelectedEntities(event->entities); + yieldToFieldSelection(); } void SelectedBuildingPanel::handleEvent(std::shared_ptr event) @@ -1167,38 +864,8 @@ void SelectedBuildingPanel::handleEvent(std::shared_ptr event) { - m_selectedDebris = event->debris; - if (!m_selectedDebris.empty()) - { - // Debris is a field object: it supersedes any building selection but coexists - // with actors (REQ-UI-SELECTION-CATEGORIES). - m_selectedBuildingIds.clear(); - } - buildFieldSelection(); -} - -int SelectedBuildingPanel::selectedDebrisScrapTotal() const -{ - // Sum the remaining scrap across the still-living selected debris (REQ-UI-DEBRIS-PANEL). - int total = 0; - for (const DebrisInfo& info : m_sim->getDebrisSystem().getAllDebrisInfo()) - { - if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), info.entity) - != m_selectedDebris.end()) - { - total += info.amount; - } - } - return total; -} - -QString SelectedBuildingPanel::scrapTotalText() const -{ - return tr("Scrap x %1").arg(selectedDebrisScrapTotal()); -} - -void SelectedBuildingPanel::handleEvent(std::shared_ptr event) -{ - m_debugDraw = event->active; - m_entityStatsPanel->setDebugDrawEnabled(event->active); + // Debris is a field object: it supersedes any building selection but coexists + // with actors (REQ-UI-SELECTION-CATEGORIES). + m_fieldSelectionPanel->setSelectedDebris(event->debris); + yieldToFieldSelection(); } diff --git a/src/ui/SelectedBuildingPanel.h b/src/ui/SelectedBuildingPanel.h index f2b6c4f..244442b 100644 --- a/src/ui/SelectedBuildingPanel.h +++ b/src/ui/SelectedBuildingPanel.h @@ -7,12 +7,9 @@ #include #include -#include "entt/entity/entity.hpp" - #include "BeltSystem.h" #include "Building.h" #include "BuildingId.h" -#include "DebugDrawToggledEvent.h" #include "EntitySelectionChangedEvent.h" #include "EventHandler.h" #include "GameConfig.h" @@ -26,20 +23,27 @@ #include "TickAdvancedEvent.h" class Simulation; +class FieldSelectionPanel; class ShipLayoutPreview; -class ShipStatsPanel; class QLabel; class QListWidget; class QPushButton; class QVBoxLayout; +// Shows the current selection. The building category (buildings and construction sites) +// is rendered by this panel itself; the field category (ships, defence stations, debris) +// is rendered by the embedded FieldSelectionPanel. +// +// The two categories are mutually exclusive (REQ-UI-SELECTION-CATEGORIES) and this panel +// is the sole arbiter of which one owns the content: it listens to all three selection +// events, forwards the field ones to the child panel, and drops the losing category's +// content. Neither panel touches the other's widgets. class SelectedBuildingPanel : public QWidget, public CombinedEventHandler + DebrisSelectionChangedEvent> { Q_OBJECT @@ -54,7 +58,6 @@ 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 onSelectRecipeClicked(); @@ -73,17 +76,14 @@ private: }; void onSelectionChanged(const std::vector& ids); + // Gives the panel to the field category once it has anything selected. + void yieldToFieldSelection(); void refreshSelectionDisplay(RefreshReason reason); void rebuild(); void hideAllWidgets(); - void clearContent(); void buildEmpty(); void buildSingle(BuildingId id); void buildMulti(const std::vector& ids); - // Summed remaining scrap across the selected debris (REQ-UI-DEBRIS-PANEL). - int selectedDebrisScrapTotal() const; - // "Scrap x N" line for the multi-object summary (REQ-UI-FIELD-MULTI-SELECTION). - QString scrapTotalText() const; void refreshBuffers(const Building* b); void refreshSiteProgress(const ConstructionSite* s); void updateShipyardLayoutWidgets(BuildingType type, @@ -116,28 +116,7 @@ private: QPoint m_splitterTile; std::string m_currentRecipeId; - bool m_debugDraw = false; - // The selected ships/defence stations. Shares the "field" selection category with - // debris (m_selectedDebris): both can be non-empty at once (REQ-UI-SELECTION-CATEGORIES). - std::vector m_selectedEntities; - ShipStatsPanel* m_entityStatsPanel; - QLabel* m_entityTitleLabel; - QLabel* m_stationStatsLabel; - QLabel* m_entitySummaryLabel; - - std::vector m_selectedDebris; - // Shows the debris "Scrap" stat row (single selection) — the scrap total for the - // multi-object summary lives in m_entitySummaryLabel instead. - QLabel* m_scrapLabel; - - // Renders the combined field selection (actors + debris): a single-object stats panel - // (ship, station, or debris) or a multi-object count summary that appends the debris - // count and scrap total when debris is also selected (REQ-UI-FIELD-MULTI-SELECTION). - void buildFieldSelection(); - void buildEntityShip(entt::entity entity); - void buildEntityStation(entt::entity entity); - void buildEntitySummary(); - void buildDebrisSingle(); - void refreshEntityStats(); - void clearEntityDisplay(); + // Renders the field selection (actors + debris) below the building content + // (REQ-UI-FIELD-MULTI-SELECTION). Hides itself while nothing field-side is selected. + FieldSelectionPanel* m_fieldSelectionPanel; };