diff --git a/docs/architecture.md b/docs/architecture.md index fa75fbc..762af23 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -124,7 +124,7 @@ Within a single simulation tick, subsystems run in this fixed order. The order i Three product targets plus tests: - `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets. -- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selected building panel, build button bar. Depends on `lib` and on Qt's OpenGL widgets module. +- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selection panel, build button bar. Depends on `lib` and on Qt's OpenGL widgets module. - `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`. - `tests/` — Catch2 tests. Links only against `lib`. diff --git a/docs/replay_design.md b/docs/replay_design.md index 47d5924..c9d12a4 100644 --- a/docs/replay_design.md +++ b/docs/replay_design.md @@ -390,11 +390,11 @@ Reshape mutations to flow through one path; behaviour unchanged. before the tick batch (runs even at 0× → build-while-paused preserved). A drained `Reset` triggers the view reset. - Refactored every UI mutation site: `GameWorldView` owns the `CommandManager` and enqueues - directly; `MainWindow` and `SelectedBuildingPanel` emit `CommandRequestedEvent` (carrying a + directly; `MainWindow` and `SelectionPanel` emit `CommandRequestedEvent` (carrying a `shared_ptr`) which `GameWorldView` subscribes to and enqueues. - **Files:** new `lib/sim/Command.h`, `CommandManager.{h,cpp}`; `CommandRequestedEvent.h`; `Simulation.{h,cpp}` (`apply`); `GameWorldView.{h,cpp}`, `MainWindow.cpp`, - `SelectedBuildingPanel.cpp`; new `CommandTest.cpp`. + `SelectionPanel.cpp`; new `CommandTest.cpp`. - **Exit criteria:** game plays identically (including build-while-paused); determinism test still passes; `[command]` equivalence tests pass; no production call site can mutate the sim directly (compile-enforced: the `Simulation` mutators are private, tests excepted via diff --git a/src/ui/BlueprintSelectionDialog.cpp b/src/ui/BlueprintSelectionDialog.cpp index fd9e288..6397793 100644 --- a/src/ui/BlueprintSelectionDialog.cpp +++ b/src/ui/BlueprintSelectionDialog.cpp @@ -158,7 +158,7 @@ BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library, // 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. + // on Windows. The border matches the build bar and the selection panel. setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint); setAttribute(Qt::WA_StyledBackground, true); setStyleSheet(QStringLiteral( diff --git a/src/ui/BuildButtonBar.cpp b/src/ui/BuildButtonBar.cpp index 7cf2ec3..be9e46d 100644 --- a/src/ui/BuildButtonBar.cpp +++ b/src/ui/BuildButtonBar.cpp @@ -192,7 +192,7 @@ BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config, // The bar floats over the rendered world rather than sitting in a panel, so it // brings its own opaque background to stay legible over any world content // (REQ-UI-BUILD-BAR). Palette colors keep it consistent with the buttons it holds - // and with the side panels; this is widget chrome, not world rendering, so it is + // and with the selection panel; this is widget chrome, not world rendering, so it is // deliberately not a visuals.toml color. setAttribute(Qt::WA_StyledBackground, true); setStyleSheet(QStringLiteral( @@ -310,6 +310,11 @@ void BuildButtonBar::anchorTo(const QRect& worldViewRect) recenter(); } +int BuildButtonBar::getStripHeightPx() const +{ + return height() + kBottomMarginPx; +} + void BuildButtonBar::clearActiveButton() { if (m_activeIndex) diff --git a/src/ui/BuildButtonBar.h b/src/ui/BuildButtonBar.h index 3b6998e..7d00978 100644 --- a/src/ui/BuildButtonBar.h +++ b/src/ui/BuildButtonBar.h @@ -51,6 +51,11 @@ public: // re-center later driven by an unlock needs no second call from the owner. void anchorTo(const QRect& worldViewRect); + // Height of the strip the bar occupies along the bottom of the world view: its own + // height plus the margin below it. The selection panel keeps out of this strip, and + // the bar never moves for the panel in return (REQ-UI-BUILD-BAR). + int getStripHeightPx() const; + void clearActiveButton(); private: diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index b553777..210c82d 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -11,7 +11,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.h ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h - ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h + ${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.h @@ -37,7 +37,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.cpp diff --git a/src/ui/FieldSelectionPanel.cpp b/src/ui/FieldSelectionPanel.cpp index 160b8cc..5bbba16 100644 --- a/src/ui/FieldSelectionPanel.cpp +++ b/src/ui/FieldSelectionPanel.cpp @@ -33,7 +33,7 @@ FieldSelectionPanel::FieldSelectionPanel(Simulation* sim, , m_sim(sim) , m_config(config) { - // Zero margins and the same spacing as the enclosing SelectedBuildingPanel layout, so + // Zero margins and the same spacing as the enclosing SelectionPanel layout, so // nesting the field widgets in this panel leaves their geometry unchanged. m_layout = new QVBoxLayout(this); m_layout->setContentsMargins(0, 0, 0, 0); diff --git a/src/ui/FieldSelectionPanel.h b/src/ui/FieldSelectionPanel.h index 540d7bb..84ccb30 100644 --- a/src/ui/FieldSelectionPanel.h +++ b/src/ui/FieldSelectionPanel.h @@ -23,8 +23,8 @@ class QVBoxLayout; // summary (REQ-UI-SELECTION-CATEGORIES, REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL). // // The panel owns its own selection state and its own widgets, and nothing else. Which of -// the two selection categories owns the side panel is arbitrated by the parent -// SelectedBuildingPanel: it feeds this panel through setSelectedEntities() / +// the two selection categories owns the selection panel is arbitrated by the parent +// SelectionPanel: it feeds this panel through setSelectedEntities() / // setSelectedDebris() / clearSelection() and asks it via hasSelection(). This panel hides // itself whenever its selection is empty, so an inactive field category takes no space. class FieldSelectionPanel : public QWidget, @@ -47,7 +47,7 @@ public: // Drops the whole field selection — used when the building category takes over. void clearSelection(); // True while the field category has anything selected, i.e. while this panel owns - // the side panel's content. + // the selection panel's content. bool hasSelection() const; private: diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 51e939c..5d2382f 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -678,7 +678,7 @@ void GameWorldView::transferConfigTo(BuildingId id, const BlueprintBuilding& sou if (source.type == BuildingType::Splitter) { // Operational splitters are configured by tile, sites by BuildingId (mirrors - // SelectedBuildingPanel::onSplitterFilterChanged). Locked item types are dropped + // SelectionPanel::onSplitterFilterChanged). Locked item types are dropped // per REQ-LOCK-UI-BLUEPRINT. const std::vector filterA = filterUnlockedItems(source.splitterFilterA, *m_sim); const std::vector filterB = filterUnlockedItems(source.splitterFilterB, *m_sim); @@ -1582,7 +1582,7 @@ void GameWorldView::handleEvent(std::shared_ptr event) { - // Other widgets (MainWindow, SelectedBuildingPanel) request commands via this + // Other widgets (MainWindow, SelectionPanel) request commands via this // event; GameWorldView owns the CommandManager and enqueues them. if (event->command && event->command->kind == CommandKind::Reset) { diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index b98a7e2..17fea10 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -28,7 +28,7 @@ #include "RecipeSelectionDialog.h" #include "SchematicChoiceDialog.h" #include "HeaderBar.h" -#include "SelectedBuildingPanel.h" +#include "SelectionPanel.h" #include "ShipLayoutBlueprintSerializer.h" #include "ShipLayoutDialog.h" #include "ItemIconCache.h" @@ -66,7 +66,7 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, const std::string iconDir = QDir::cleanPath( QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString(); - // Floats over the game world rather than living in the side panel column + // Floats over the game world at its bottom center, sized to its buttons // (REQ-UI-BUILD-BAR). Creation order is the stacking order for siblings, so // building it after the world view puts it above the world and its vignettes, // and before the dim overlay keeps modals dimming it too (REQ-UI-MODAL-DIM). @@ -80,23 +80,12 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, // 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); - - // 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); - - // 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 { border: 1px solid palette(mid); }")); + // Floats over the game world at its right edge rather than occupying a column of + // its own, and hides itself while nothing is selected (REQ-UI-SELECTION-PANEL). Like + // the build button bar it is a sibling of the world view built after it, which is + // what puts it above the world and its vignettes and below the dim overlay. It + // brings its own chrome; its geometry comes from layoutPanels(). + m_selectionPanel = new SelectionPanel(sim, &sim->getConfig(), this); // Created last so it stacks above the other children; covers the whole window and // dims the game behind modal dialogs/menus (REQ-UI-MODAL-DIM). @@ -166,15 +155,20 @@ void MainWindow::layoutPanels() const int totalH = height(); const int headerH = m_headerBar->sizeHint().height(); if (headerH <= 0) { return; } - const int mainW = totalW * 75 / 100; - const int sideW = totalW - mainW; - m_headerBar->setGeometry(0, 0, mainW, headerH); - m_gameWorldView->setGeometry(0, headerH, mainW, totalH - headerH); - m_sidePanel->setGeometry(mainW, 0, sideW, totalH); + // Header bar and game world view span the full window width; the two floating + // widgets below are the only things over the world (REQ-UI-HEADER, + // REQ-UI-WORLD-SIZE). + const QRect worldRect(0, headerH, totalW, totalH - headerH); + m_headerBar->setGeometry(0, 0, totalW, headerH); + m_gameWorldView->setGeometry(worldRect); // Sizes itself to its buttons and centers along the bottom of the world view // (REQ-UI-BUILD-BAR). - m_buildButtonBar->anchorTo(QRect(0, headerH, mainW, totalH - headerH)); + m_buildButtonBar->anchorTo(worldRect); + // The panel confines itself to what the bar leaves free, so the bar never has to + // move for it (REQ-UI-SELECTION-PANEL, REQ-UI-BUILD-BAR). + m_selectionPanel->anchorTo( + worldRect.adjusted(0, 0, 0, -m_buildButtonBar->getStripHeightPx())); m_dimOverlay->setGeometry(0, 0, totalW, totalH); } diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index cb10a4c..192a2a1 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -28,7 +28,7 @@ struct ParsedReplay; class Simulation; class GameWorldView; class HeaderBar; -class SelectedBuildingPanel; +class SelectionPanel; class BuildButtonBar; class BlueprintLibrary; class ItemIconCache; @@ -94,12 +94,11 @@ private: std::unique_ptr m_itemIcons; GameWorldView* m_gameWorldView; HeaderBar* m_headerBar; - SelectedBuildingPanel* m_selectedBuildingPanel; + SelectionPanel* m_selectionPanel; BuildButtonBar* m_buildButtonBar; // The saved blueprints themselves; they have no widget of their own any more and // are reached through the two modal dialogs (REQ-UI-BLUEPRINT-DIALOG). std::unique_ptr m_blueprintLibrary; - QWidget* m_sidePanel; ModalDimOverlay* m_dimOverlay = nullptr; std::vector m_layoutBlueprints; diff --git a/src/ui/SelectedBuildingPanel.cpp b/src/ui/SelectionPanel.cpp similarity index 73% rename from src/ui/SelectedBuildingPanel.cpp rename to src/ui/SelectionPanel.cpp index 86f8963..246953a 100644 --- a/src/ui/SelectedBuildingPanel.cpp +++ b/src/ui/SelectionPanel.cpp @@ -1,4 +1,4 @@ -#include "SelectedBuildingPanel.h" +#include "SelectionPanel.h" #include "FactoryQueries.h" #include @@ -10,6 +10,9 @@ #include #include #include +#include +#include +#include #include #include "BeltSystem.h" @@ -35,6 +38,17 @@ namespace { +// Distance kept between the panel and the edges of the band it is anchored to +// (REQ-UI-SELECTION-PANEL). +const int kMarginPx = 8; + +// Upper bound on the content width. The panel is content-sized, but several of its +// widgets have no natural width of their own -- the word-wrapped buffer and summary +// labels grow without limit, and a QListWidget asks for 256 px whatever it holds -- so +// the width is capped and the labels wrap at the cap. 320 px is the width the former +// side panel column had at the default window size. +const int kMaxContentWidthPx = 320; + QString buildingTypeName(BuildingType type) { if (type == BuildingType::Hq) @@ -100,29 +114,59 @@ QString rotationLabel(Rotation r) } // namespace -SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim, - const GameConfig* config, - QWidget* parent) +SelectionPanel::SelectionPanel(Simulation* sim, + const GameConfig* config, + QWidget* parent) : QWidget(parent) , m_sim(sim) , m_config(config) , m_splitterTile(0, 0) { - m_layout = new QVBoxLayout(this); + // The panel floats over the rendered world rather than sitting in a column, so it + // brings its own opaque background to stay legible over any world content + // (REQ-UI-SELECTION-PANEL). Palette colors match the build button bar's chrome; like + // it, this is widget chrome rather than world rendering, so it is deliberately not a + // visuals.toml color. The class scoped selector keeps the border on the panel itself + // rather than cascading onto its child widgets. + setAttribute(Qt::WA_StyledBackground, true); + setStyleSheet(QStringLiteral( + "SelectionPanel { background-color: palette(window);" + " border: 1px solid palette(mid); border-radius: 4px; }")); + + // Content taller than the band scrolls rather than overrunning it + // (REQ-UI-SELECTION-PANEL). The viewport is transparent so the panel's own rounded + // chrome shows through, and horizontal scrolling is off because the width always + // follows the content. + m_content = new QWidget(this); + m_scrollArea = new QScrollArea(this); + m_scrollArea->setFrameShape(QFrame::NoFrame); + m_scrollArea->setWidgetResizable(true); + m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_scrollArea->viewport()->setAutoFillBackground(false); + m_content->setAutoFillBackground(false); + m_scrollArea->setWidget(m_content); + + QVBoxLayout* outerLayout = new QVBoxLayout(this); + outerLayout->setContentsMargins(0, 0, 0, 0); + outerLayout->setSpacing(0); + outerLayout->addWidget(m_scrollArea); + + m_layout = new QVBoxLayout(m_content); m_layout->setContentsMargins(8, 8, 8, 8); m_layout->setSpacing(4); m_layout->setAlignment(Qt::AlignTop); - m_titleLabel = new QLabel(this); - m_recipeSelectButton = new QPushButton(this); - m_clearBeltBtn = new QPushButton(tr("Clear Items"), this); - m_filterALabel = new QLabel(this); - m_filterAList = new QListWidget(this); - m_filterBLabel = new QLabel(this); - m_filterBList = new QListWidget(this); - m_layoutPreview = new ShipLayoutPreview(this); - m_configureLayoutBtn = new QPushButton(tr("Configure Layout"), this); - m_buffersLabel = new QLabel(this); + m_titleLabel = new QLabel(m_content); + m_recipeSelectButton = new QPushButton(m_content); + m_clearBeltBtn = new QPushButton(tr("Clear Items"), m_content); + m_filterALabel = new QLabel(m_content); + m_filterAList = new QListWidget(m_content); + m_filterBLabel = new QLabel(m_content); + m_filterBList = new QListWidget(m_content); + m_layoutPreview = new ShipLayoutPreview(m_content); + m_configureLayoutBtn = new QPushButton(tr("Configure Layout"), m_content); + m_buffersLabel = new QLabel(m_content); m_buffersLabel->setWordWrap(true); m_filterAList->setMaximumHeight(100); @@ -140,9 +184,9 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim, m_layout->addWidget(m_buffersLabel); connect(m_recipeSelectButton, &QPushButton::clicked, - this, &SelectedBuildingPanel::onSelectRecipeClicked); + this, &SelectionPanel::onSelectRecipeClicked); connect(m_clearBeltBtn, &QPushButton::clicked, - this, &SelectedBuildingPanel::onClearBelt); + this, &SelectionPanel::onClearBelt); connect(m_configureLayoutBtn, &QPushButton::clicked, this, [this]() { if (m_singleBuildingId.has_value()) { @@ -151,13 +195,13 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim, } }); connect(m_filterAList, &QListWidget::itemChanged, - this, &SelectedBuildingPanel::onSplitterFilterChanged); + this, &SelectionPanel::onSplitterFilterChanged); connect(m_filterBList, &QListWidget::itemChanged, - this, &SelectedBuildingPanel::onSplitterFilterChanged); + this, &SelectionPanel::onSplitterFilterChanged); // 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_fieldSelectionPanel = new FieldSelectionPanel(sim, config, m_content); m_layout->addWidget(m_fieldSelectionPanel); buildEmpty(); @@ -165,12 +209,18 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim, registerForEvents(); } -SelectedBuildingPanel::~SelectedBuildingPanel() +SelectionPanel::~SelectionPanel() { unregisterForEvents(); } -void SelectedBuildingPanel::onSelectionChanged(const std::vector& ids) +void SelectionPanel::anchorTo(const QRect& bandRect) +{ + m_bandRect = bandRect; + updateVisibility(); +} + +void SelectionPanel::onSelectionChanged(const std::vector& ids) { m_selectedBuildingIds = ids; if (!ids.empty()) @@ -182,17 +232,92 @@ void SelectedBuildingPanel::onSelectionChanged(const std::vector& id rebuild(); } -void SelectedBuildingPanel::yieldToFieldSelection() +void SelectionPanel::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(); + // field selection leaves the content alone: the building content, if any, keeps the + // panel; it only has to be re-checked for whether anything is left to show at all. + if (!m_fieldSelectionPanel->hasSelection()) + { + updateVisibility(); + return; + } buildEmpty(); } -void SelectedBuildingPanel::rebuild() +void SelectionPanel::updateVisibility() +{ + // Nothing selected in either category means no panel at all rather than an empty + // one (REQ-UI-EMPTY-SELECTION), leaving the whole game world view visible. Before + // the owner has anchored the panel there is nowhere to put it either, so it stays + // hidden until then. + const bool shouldShow = + (!m_selectedBuildingIds.empty() || m_fieldSelectionPanel->hasSelection()) + && !m_bandRect.isNull(); + setVisible(shouldShow); + if (shouldShow) + { + refit(); + } +} + +// Only ever reached through updateVisibility(), i.e. with a band to fit into. +void SelectionPanel::refit() +{ + // The layout drops hidden widgets from its size hint, but only once it has been + // re-run: the rebuild paths call this straight after hide()/show(), before Qt would + // get around to it on its own. + m_content->layout()->activate(); + + const QRect band = + m_bandRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx); + + // The panel's border is drawn around the scroll area rather than around the + // content, so it is added to whatever the content asks for. Spelled out here + // instead of read back from contentsMargins() because the stylesheet box is what + // sets it, and asking the style for it before the first show is unreliable. + const int borderPx = 1; + const int maxWidthPx = qMin(kMaxContentWidthPx, band.width() - 2 * borderPx); + const int maxHeightPx = band.height() - 2 * borderPx; + if (maxWidthPx <= 0 || maxHeightPx <= 0) + { + return; + } + + int contentWidthPx = qMin(m_content->sizeHint().width(), maxWidthPx); + // Word-wrapped labels only know their height once the width is fixed; the layout + // reports -1 when nothing in it wraps, in which case the plain hint is exact. + int contentHeightPx = m_content->heightForWidth(contentWidthPx); + if (contentHeightPx < 0) + { + contentHeightPx = m_content->sizeHint().height(); + } + + if (contentHeightPx > maxHeightPx) + { + // Content taller than the band is capped there and scrolls + // (REQ-UI-SELECTION-PANEL). The scroll bar is laid out beside the content, so + // the panel widens by its width to keep the content as wide as the height was + // computed for. + contentHeightPx = maxHeightPx; + contentWidthPx = qMin( + contentWidthPx + m_scrollArea->verticalScrollBar()->sizeHint().width(), + maxWidthPx); + } + + const int panelWidthPx = contentWidthPx + 2 * borderPx; + const int panelHeightPx = contentHeightPx + 2 * borderPx; + + // Right-aligned in the band and centered on it vertically. The band excludes the + // build button bar's strip, so centering here never puts the panel over the bar + // (REQ-UI-SELECTION-PANEL). + setGeometry(band.right() - panelWidthPx + 1, + band.top() + (band.height() - panelHeightPx) / 2, + panelWidthPx, panelHeightPx); +} + +void SelectionPanel::rebuild() { if (m_selectedBuildingIds.empty()) { @@ -208,7 +333,7 @@ void SelectedBuildingPanel::rebuild() } } -void SelectedBuildingPanel::hideAllWidgets() +void SelectionPanel::hideAllWidgets() { m_titleLabel->hide(); m_recipeSelectButton->hide(); @@ -222,15 +347,20 @@ void SelectedBuildingPanel::hideAllWidgets() m_buffersLabel->hide(); } -void SelectedBuildingPanel::buildEmpty() +void SelectionPanel::buildEmpty() { // 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; + // Also reached when the selected building has gone away under the panel (it was + // deconstructed, or its site finished building): dropping the ids keeps them from + // outliving the content and holding the panel on screen (REQ-UI-EMPTY-SELECTION). + m_selectedBuildingIds.clear(); hideAllWidgets(); + updateVisibility(); } -void SelectedBuildingPanel::buildSingle(BuildingId id) +void SelectionPanel::buildSingle(BuildingId id) { m_singleBuildingId = id; hideAllWidgets(); @@ -341,7 +471,7 @@ void SelectedBuildingPanel::buildSingle(BuildingId id) } } -void SelectedBuildingPanel::refreshSiteProgress(const ConstructionSite* s) +void SelectionPanel::refreshSiteProgress(const ConstructionSite* s) { QString progress; if (s->completesAt == 0) @@ -369,9 +499,13 @@ void SelectedBuildingPanel::refreshSiteProgress(const ConstructionSite* s) } } m_buffersLabel->setText(progress); + + // The progress line changes width as it counts up, and the panel is sized to its + // content, so every refresh re-fits it (REQ-UI-SELECTION-PANEL). + updateVisibility(); } -void SelectedBuildingPanel::refreshBuffers(const Building* b) +void SelectionPanel::refreshBuffers(const Building* b) { const RecipeDef* recipe = findRecipe(b); const ShipDef* shipDef = (b->type == BuildingType::Shipyard) @@ -528,9 +662,13 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b) // Configure Layout button's visibility; otherwise they stay hidden until the // building is re-selected (which re-runs buildSingle). updateShipyardLayoutWidgets(b->type, b->recipeId, b->shipLayout); + + // Buffer counts and the progress line change width as they run, and the panel is + // sized to its content, so every refresh re-fits it (REQ-UI-SELECTION-PANEL). + updateVisibility(); } -void SelectedBuildingPanel::updateShipyardLayoutWidgets( +void SelectionPanel::updateShipyardLayoutWidgets( BuildingType type, const std::string& recipeId, const std::optional& shipLayout) @@ -570,24 +708,24 @@ void SelectedBuildingPanel::updateShipyardLayoutWidgets( m_configureLayoutBtn->show(); } -const RecipeDef* SelectedBuildingPanel::findRecipe(const Building* b) const +const RecipeDef* SelectionPanel::findRecipe(const Building* b) const { if (b->recipeId.empty()) { return nullptr; } return m_config->recipes.findRecipeDef(b->recipeId, b->type); } -const ShipDef* SelectedBuildingPanel::findShipDef(const std::string& id) const +const ShipDef* SelectionPanel::findShipDef(const std::string& id) const { if (id.empty()) { return nullptr; } return m_config->ships.findShipDef(id); } -void SelectedBuildingPanel::handleEvent(std::shared_ptr /*event*/) +void SelectionPanel::handleEvent(std::shared_ptr /*event*/) { refreshSelectionDisplay(RefreshReason::PeriodicTick); } -void SelectedBuildingPanel::handleEvent( +void SelectionPanel::handleEvent( std::shared_ptr /*event*/) { // Player commands (e.g. choosing a shipyard schematic) are applied by a @@ -597,12 +735,18 @@ void SelectedBuildingPanel::handleEvent( refreshSelectionDisplay(RefreshReason::CommandApplied); } -void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason) +void SelectionPanel::refreshSelectionDisplay(RefreshReason reason) { // 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; } + // Its content changes size as it does (a ship's HP, a debris pile's scrap), so the + // panel is re-fitted to it before returning. + if (!m_singleBuildingId.has_value()) + { + updateVisibility(); + return; + } const Building* b = findBuilding(m_sim->getFactoryState(), *m_singleBuildingId); if (b) { @@ -636,7 +780,7 @@ void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason) buildEmpty(); } -void SelectedBuildingPanel::buildMulti(const std::vector& ids) +void SelectionPanel::buildMulti(const std::vector& ids) { m_singleBuildingId = std::nullopt; m_recipeSelectButton->hide(); @@ -646,6 +790,11 @@ void SelectedBuildingPanel::buildMulti(const std::vector& ids) m_filterBLabel->hide(); m_filterBList->hide(); m_buffersLabel->hide(); + // Per-building detail is not shown for a multi-selection (REQ-UI-MULTI-SELECTION), + // so a shipyard's preview must not survive from a previous single selection — it + // would both mislead and pad the content-sized panel. + m_layoutPreview->hide(); + m_configureLayoutBtn->hide(); std::map counts; for (BuildingId id : ids) @@ -690,9 +839,11 @@ void SelectedBuildingPanel::buildMulti(const std::vector& ids) { m_clearBeltBtn->show(); } + + updateVisibility(); } -void SelectedBuildingPanel::onSelectRecipeClicked() +void SelectionPanel::onSelectRecipeClicked() { if (!m_singleBuildingId.has_value()) { @@ -709,7 +860,7 @@ void SelectedBuildingPanel::onSelectRecipeClicked() rebuild(); } -void SelectedBuildingPanel::buildSplitterFilters( +void SelectionPanel::buildSplitterFilters( const std::optional& info) { if (!info.has_value()) @@ -753,7 +904,7 @@ void SelectedBuildingPanel::buildSplitterFilters( rotationLabel(info->outputB), info->filterB); } -void SelectedBuildingPanel::onSplitterFilterChanged() +void SelectionPanel::onSplitterFilterChanged() { if (!m_singleBuildingId.has_value()) { @@ -796,7 +947,7 @@ void SelectedBuildingPanel::onSplitterFilterChanged() } } -std::vector SelectedBuildingPanel::getAllItemIds() const +std::vector SelectionPanel::getAllItemIds() const { std::set seen; for (const RecipeDef& recipe : m_config->recipes.recipes) @@ -813,7 +964,7 @@ std::vector SelectedBuildingPanel::getAllItemIds() const return std::vector(seen.begin(), seen.end()); } -void SelectedBuildingPanel::onClearBelt() +void SelectionPanel::onClearBelt() { std::vector tiles; for (BuildingId id : m_selectedBuildingIds) @@ -837,18 +988,18 @@ void SelectedBuildingPanel::onClearBelt() } } -void SelectedBuildingPanel::handleEvent(std::shared_ptr event) +void SelectionPanel::handleEvent(std::shared_ptr event) { m_fieldSelectionPanel->setSelectedEntities(event->entities); yieldToFieldSelection(); } -void SelectedBuildingPanel::handleEvent(std::shared_ptr event) +void SelectionPanel::handleEvent(std::shared_ptr event) { onSelectionChanged(event->ids); } -void SelectedBuildingPanel::handleEvent( +void SelectionPanel::handleEvent( std::shared_ptr event) { // Debris is a field object: it supersedes any building selection but coexists diff --git a/src/ui/SelectedBuildingPanel.h b/src/ui/SelectionPanel.h similarity index 66% rename from src/ui/SelectedBuildingPanel.h rename to src/ui/SelectionPanel.h index 244442b..d7a7cb5 100644 --- a/src/ui/SelectedBuildingPanel.h +++ b/src/ui/SelectionPanel.h @@ -5,6 +5,7 @@ #include #include +#include #include #include "BeltSystem.h" @@ -28,6 +29,7 @@ class ShipLayoutPreview; class QLabel; class QListWidget; class QPushButton; +class QScrollArea; class QVBoxLayout; // Shows the current selection. The building category (buildings and construction sites) @@ -38,19 +40,30 @@ class QVBoxLayout; // 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 +// +// The panel floats over the game world view rather than occupying a column of its own +// (REQ-UI-SELECTION-PANEL): it sizes itself to its content, anchors to the right edge of +// the band its owner hands it, and hides itself entirely while nothing is selected +// (REQ-UI-EMPTY-SELECTION). +class SelectionPanel : public QWidget, + public CombinedEventHandler { Q_OBJECT public: - SelectedBuildingPanel(Simulation* sim, const GameConfig* config, - QWidget* parent = nullptr); - ~SelectedBuildingPanel() override; + SelectionPanel(Simulation* sim, const GameConfig* config, + QWidget* parent = nullptr); + ~SelectionPanel() override; + + // Confines the panel to the given band of the game world view: it right-aligns + // within it and centers vertically in it. The band is the world view less the strip + // the build button bar occupies, so the two never overlap and the bar never has to + // move (REQ-UI-SELECTION-PANEL, REQ-UI-BUILD-BAR). + void anchorTo(const QRect& bandRect); private: void handleEvent(std::shared_ptr event) override; @@ -78,6 +91,13 @@ private: void onSelectionChanged(const std::vector& ids); // Gives the panel to the field category once it has anything selected. void yieldToFieldSelection(); + // Shows the panel while either category has a selection and hides it otherwise + // (REQ-UI-EMPTY-SELECTION), re-fitting it to its current content while it is shown. + // Every path that changes the content ends here, because the content is what sizes + // the panel. + void updateVisibility(); + // Re-fits the panel to its content within the anchored band. + void refit(); void refreshSelectionDisplay(RefreshReason reason); void rebuild(); void hideAllWidgets(); @@ -98,6 +118,15 @@ private: const GameConfig* m_config; std::vector m_selectedBuildingIds; + // The band the panel confines itself to, in the coordinates of its parent; null + // until the owner has anchored it for the first time. + QRect m_bandRect; + + // Scrolls the content once it outgrows the band (REQ-UI-SELECTION-PANEL). All the + // content widgets below are children of m_content, not of the panel itself. + QScrollArea* m_scrollArea; + QWidget* m_content; + QVBoxLayout* m_layout; QLabel* m_titleLabel; QPushButton* m_recipeSelectButton;