From 668ce0fcb8d3c8a9bb2b9150718609f28335b4d2 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Thu, 6 Aug 2026 21:58:36 +0200 Subject: [PATCH] float the selection panel over the game world instead of a side column Implements REQ-UI-SELECTION-PANEL and the full-width REQ-UI-WORLD-SIZE / REQ-UI-HEADER: MainWindow drops the 25% side column and its 75/25 math, so the header bar and world view span the window, and the panel joins the build button bar as a widget floating over the world. The panel follows the bar's pattern -- an opaque sibling built after the world view, so it sits above the vignettes, below the dim overlay, and swallows the mouse events that would otherwise reach the world. It sizes itself to its content within a band the window hands it (the world view less the bar's strip, so the bar never has to move for it), right-aligned and centered in that band, and scrolls once the content outgrows it. Its content moved into a scroll area for that; the width is capped at 320 px because the wrapped labels and the splitter filter lists have no natural width of their own. With nothing selected the panel now hides entirely rather than showing an empty box (REQ-UI-EMPTY-SELECTION). Two defects that content sizing exposed: buildEmpty() left the selected ids behind when the building vanished under the panel, which would have held an empty panel on screen, and buildMulti() let a shipyard's layout preview survive from a previous single selection (REQ-UI-MULTI-SELECTION). Renames SelectedBuildingPanel to SelectionPanel throughout, matching the requirements: the panel has long shown ships, stations, and debris too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K --- docs/architecture.md | 2 +- docs/replay_design.md | 4 +- src/ui/BlueprintSelectionDialog.cpp | 2 +- src/ui/BuildButtonBar.cpp | 7 +- src/ui/BuildButtonBar.h | 5 + src/ui/CMakeLists.txt | 4 +- src/ui/FieldSelectionPanel.cpp | 2 +- src/ui/FieldSelectionPanel.h | 6 +- src/ui/GameWorldView.cpp | 4 +- src/ui/MainWindow.cpp | 44 ++-- src/ui/MainWindow.h | 5 +- ...edBuildingPanel.cpp => SelectionPanel.cpp} | 247 ++++++++++++++---- ...lectedBuildingPanel.h => SelectionPanel.h} | 47 +++- 13 files changed, 281 insertions(+), 98 deletions(-) rename src/ui/{SelectedBuildingPanel.cpp => SelectionPanel.cpp} (73%) rename src/ui/{SelectedBuildingPanel.h => SelectionPanel.h} (66%) 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;