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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
This commit is contained in:
2026-08-06 21:58:36 +02:00
parent 7ddb4a1bb5
commit 668ce0fcb8
13 changed files with 281 additions and 98 deletions

View File

@@ -124,7 +124,7 @@ Within a single simulation tick, subsystems run in this fixed order. The order i
Three product targets plus tests: Three product targets plus tests:
- `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets. - `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`. - `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`.
- `tests/` — Catch2 tests. Links only against `lib`. - `tests/` — Catch2 tests. Links only against `lib`.

View File

@@ -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` before the tick batch (runs even at 0× → build-while-paused preserved). A drained `Reset`
triggers the view reset. triggers the view reset.
- Refactored every UI mutation site: `GameWorldView` owns the `CommandManager` and enqueues - 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<const Command>`) which `GameWorldView` subscribes to and enqueues. `shared_ptr<const Command>`) which `GameWorldView` subscribes to and enqueues.
- **Files:** new `lib/sim/Command.h`, `CommandManager.{h,cpp}`; `CommandRequestedEvent.h`; - **Files:** new `lib/sim/Command.h`, `CommandManager.{h,cpp}`; `CommandRequestedEvent.h`;
`Simulation.{h,cpp}` (`apply`); `GameWorldView.{h,cpp}`, `MainWindow.cpp`, `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 - **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 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 directly (compile-enforced: the `Simulation` mutators are private, tests excepted via

View File

@@ -158,7 +158,7 @@ BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library,
// Frameless: the dialog draws its own header row, so an OS title bar would only // 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 -- // repeat it (REQ-UI-BLUEPRINT-DIALOG). Square corners rather than rounded ones --
// rounding a top-level window needs a translucent background, which is unreliable // 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); setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint);
setAttribute(Qt::WA_StyledBackground, true); setAttribute(Qt::WA_StyledBackground, true);
setStyleSheet(QStringLiteral( setStyleSheet(QStringLiteral(

View File

@@ -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 // 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 // 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 // (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. // deliberately not a visuals.toml color.
setAttribute(Qt::WA_StyledBackground, true); setAttribute(Qt::WA_StyledBackground, true);
setStyleSheet(QStringLiteral( setStyleSheet(QStringLiteral(
@@ -310,6 +310,11 @@ void BuildButtonBar::anchorTo(const QRect& worldViewRect)
recenter(); recenter();
} }
int BuildButtonBar::getStripHeightPx() const
{
return height() + kBottomMarginPx;
}
void BuildButtonBar::clearActiveButton() void BuildButtonBar::clearActiveButton()
{ {
if (m_activeIndex) if (m_activeIndex)

View File

@@ -51,6 +51,11 @@ public:
// re-center later driven by an unlock needs no second call from the owner. // re-center later driven by an unlock needs no second call from the owner.
void anchorTo(const QRect& worldViewRect); 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(); void clearActiveButton();
private: private:

View File

@@ -11,7 +11,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.h ${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.h
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.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}/FieldSelectionPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.h
@@ -37,7 +37,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.cpp
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.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}/FieldSelectionPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.cpp

View File

@@ -33,7 +33,7 @@ FieldSelectionPanel::FieldSelectionPanel(Simulation* sim,
, m_sim(sim) , m_sim(sim)
, m_config(config) , 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. // nesting the field widgets in this panel leaves their geometry unchanged.
m_layout = new QVBoxLayout(this); m_layout = new QVBoxLayout(this);
m_layout->setContentsMargins(0, 0, 0, 0); m_layout->setContentsMargins(0, 0, 0, 0);

View File

@@ -23,8 +23,8 @@ class QVBoxLayout;
// summary (REQ-UI-SELECTION-CATEGORIES, REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL). // 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 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 // the two selection categories owns the selection panel is arbitrated by the parent
// SelectedBuildingPanel: it feeds this panel through setSelectedEntities() / // SelectionPanel: it feeds this panel through setSelectedEntities() /
// setSelectedDebris() / clearSelection() and asks it via hasSelection(). This panel hides // setSelectedDebris() / clearSelection() and asks it via hasSelection(). This panel hides
// itself whenever its selection is empty, so an inactive field category takes no space. // itself whenever its selection is empty, so an inactive field category takes no space.
class FieldSelectionPanel : public QWidget, class FieldSelectionPanel : public QWidget,
@@ -47,7 +47,7 @@ public:
// Drops the whole field selection — used when the building category takes over. // Drops the whole field selection — used when the building category takes over.
void clearSelection(); void clearSelection();
// True while the field category has anything selected, i.e. while this panel owns // 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; bool hasSelection() const;
private: private:

View File

@@ -678,7 +678,7 @@ void GameWorldView::transferConfigTo(BuildingId id, const BlueprintBuilding& sou
if (source.type == BuildingType::Splitter) if (source.type == BuildingType::Splitter)
{ {
// Operational splitters are configured by tile, sites by BuildingId (mirrors // 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. // per REQ-LOCK-UI-BLUEPRINT.
const std::vector<ItemType> filterA = filterUnlockedItems(source.splitterFilterA, *m_sim); const std::vector<ItemType> filterA = filterUnlockedItems(source.splitterFilterA, *m_sim);
const std::vector<ItemType> filterB = filterUnlockedItems(source.splitterFilterB, *m_sim); const std::vector<ItemType> filterB = filterUnlockedItems(source.splitterFilterB, *m_sim);
@@ -1582,7 +1582,7 @@ void GameWorldView::handleEvent(std::shared_ptr<const DebugDrawToggleRequestedEv
void GameWorldView::handleEvent(std::shared_ptr<const CommandRequestedEvent> event) void GameWorldView::handleEvent(std::shared_ptr<const CommandRequestedEvent> 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. // event; GameWorldView owns the CommandManager and enqueues them.
if (event->command && event->command->kind == CommandKind::Reset) if (event->command && event->command->kind == CommandKind::Reset)
{ {

View File

@@ -28,7 +28,7 @@
#include "RecipeSelectionDialog.h" #include "RecipeSelectionDialog.h"
#include "SchematicChoiceDialog.h" #include "SchematicChoiceDialog.h"
#include "HeaderBar.h" #include "HeaderBar.h"
#include "SelectedBuildingPanel.h" #include "SelectionPanel.h"
#include "ShipLayoutBlueprintSerializer.h" #include "ShipLayoutBlueprintSerializer.h"
#include "ShipLayoutDialog.h" #include "ShipLayoutDialog.h"
#include "ItemIconCache.h" #include "ItemIconCache.h"
@@ -66,7 +66,7 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
const std::string iconDir = QDir::cleanPath( const std::string iconDir = QDir::cleanPath(
QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString(); 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 // (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, // 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). // 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. // world view because loading blueprints.toml may put a message box on screen.
m_blueprintLibrary = std::make_unique<BlueprintLibrary>(sim, &sim->getConfig(), this); m_blueprintLibrary = std::make_unique<BlueprintLibrary>(sim, &sim->getConfig(), this);
m_sidePanel = new QWidget(this); // Floats over the game world at its right edge rather than occupying a column of
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel); // its own, and hides itself while nothing is selected (REQ-UI-SELECTION-PANEL). Like
sideLayout->setContentsMargins(1, 1, 1, 1); // the build button bar it is a sibling of the world view built after it, which is
sideLayout->setSpacing(1); // what puts it above the world and its vignettes and below the dim overlay. It
// brings its own chrome; its geometry comes from layoutPanels().
// The selected building panel is the column's only panel and fills its height m_selectionPanel = new SelectionPanel(sim, &sim->getConfig(), this);
// (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); }"));
// Created last so it stacks above the other children; covers the whole window and // 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). // dims the game behind modal dialogs/menus (REQ-UI-MODAL-DIM).
@@ -166,15 +155,20 @@ void MainWindow::layoutPanels()
const int totalH = height(); const int totalH = height();
const int headerH = m_headerBar->sizeHint().height(); const int headerH = m_headerBar->sizeHint().height();
if (headerH <= 0) { return; } if (headerH <= 0) { return; }
const int mainW = totalW * 75 / 100;
const int sideW = totalW - mainW;
m_headerBar->setGeometry(0, 0, mainW, headerH); // Header bar and game world view span the full window width; the two floating
m_gameWorldView->setGeometry(0, headerH, mainW, totalH - headerH); // widgets below are the only things over the world (REQ-UI-HEADER,
m_sidePanel->setGeometry(mainW, 0, sideW, totalH); // 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 // Sizes itself to its buttons and centers along the bottom of the world view
// (REQ-UI-BUILD-BAR). // (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); m_dimOverlay->setGeometry(0, 0, totalW, totalH);
} }

View File

@@ -28,7 +28,7 @@ struct ParsedReplay;
class Simulation; class Simulation;
class GameWorldView; class GameWorldView;
class HeaderBar; class HeaderBar;
class SelectedBuildingPanel; class SelectionPanel;
class BuildButtonBar; class BuildButtonBar;
class BlueprintLibrary; class BlueprintLibrary;
class ItemIconCache; class ItemIconCache;
@@ -94,12 +94,11 @@ private:
std::unique_ptr<ItemIconCache> m_itemIcons; std::unique_ptr<ItemIconCache> m_itemIcons;
GameWorldView* m_gameWorldView; GameWorldView* m_gameWorldView;
HeaderBar* m_headerBar; HeaderBar* m_headerBar;
SelectedBuildingPanel* m_selectedBuildingPanel; SelectionPanel* m_selectionPanel;
BuildButtonBar* m_buildButtonBar; BuildButtonBar* m_buildButtonBar;
// The saved blueprints themselves; they have no widget of their own any more and // 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). // are reached through the two modal dialogs (REQ-UI-BLUEPRINT-DIALOG).
std::unique_ptr<BlueprintLibrary> m_blueprintLibrary; std::unique_ptr<BlueprintLibrary> m_blueprintLibrary;
QWidget* m_sidePanel;
ModalDimOverlay* m_dimOverlay = nullptr; ModalDimOverlay* m_dimOverlay = nullptr;
std::vector<ShipLayoutBlueprint> m_layoutBlueprints; std::vector<ShipLayoutBlueprint> m_layoutBlueprints;

View File

@@ -1,4 +1,4 @@
#include "SelectedBuildingPanel.h" #include "SelectionPanel.h"
#include "FactoryQueries.h" #include "FactoryQueries.h"
#include <algorithm> #include <algorithm>
@@ -10,6 +10,9 @@
#include <QLabel> #include <QLabel>
#include <QListWidget> #include <QListWidget>
#include <QPushButton> #include <QPushButton>
#include <QScrollArea>
#include <QScrollBar>
#include <QStyle>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "BeltSystem.h" #include "BeltSystem.h"
@@ -35,6 +38,17 @@
namespace 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) QString buildingTypeName(BuildingType type)
{ {
if (type == BuildingType::Hq) if (type == BuildingType::Hq)
@@ -100,29 +114,59 @@ QString rotationLabel(Rotation r)
} // namespace } // namespace
SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim, SelectionPanel::SelectionPanel(Simulation* sim,
const GameConfig* config, const GameConfig* config,
QWidget* parent) QWidget* parent)
: QWidget(parent) : QWidget(parent)
, m_sim(sim) , m_sim(sim)
, m_config(config) , m_config(config)
, m_splitterTile(0, 0) , 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->setContentsMargins(8, 8, 8, 8);
m_layout->setSpacing(4); m_layout->setSpacing(4);
m_layout->setAlignment(Qt::AlignTop); m_layout->setAlignment(Qt::AlignTop);
m_titleLabel = new QLabel(this); m_titleLabel = new QLabel(m_content);
m_recipeSelectButton = new QPushButton(this); m_recipeSelectButton = new QPushButton(m_content);
m_clearBeltBtn = new QPushButton(tr("Clear Items"), this); m_clearBeltBtn = new QPushButton(tr("Clear Items"), m_content);
m_filterALabel = new QLabel(this); m_filterALabel = new QLabel(m_content);
m_filterAList = new QListWidget(this); m_filterAList = new QListWidget(m_content);
m_filterBLabel = new QLabel(this); m_filterBLabel = new QLabel(m_content);
m_filterBList = new QListWidget(this); m_filterBList = new QListWidget(m_content);
m_layoutPreview = new ShipLayoutPreview(this); m_layoutPreview = new ShipLayoutPreview(m_content);
m_configureLayoutBtn = new QPushButton(tr("Configure Layout"), this); m_configureLayoutBtn = new QPushButton(tr("Configure Layout"), m_content);
m_buffersLabel = new QLabel(this); m_buffersLabel = new QLabel(m_content);
m_buffersLabel->setWordWrap(true); m_buffersLabel->setWordWrap(true);
m_filterAList->setMaximumHeight(100); m_filterAList->setMaximumHeight(100);
@@ -140,9 +184,9 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
m_layout->addWidget(m_buffersLabel); m_layout->addWidget(m_buffersLabel);
connect(m_recipeSelectButton, &QPushButton::clicked, connect(m_recipeSelectButton, &QPushButton::clicked,
this, &SelectedBuildingPanel::onSelectRecipeClicked); this, &SelectionPanel::onSelectRecipeClicked);
connect(m_clearBeltBtn, &QPushButton::clicked, connect(m_clearBeltBtn, &QPushButton::clicked,
this, &SelectedBuildingPanel::onClearBelt); this, &SelectionPanel::onClearBelt);
connect(m_configureLayoutBtn, &QPushButton::clicked, this, [this]() { connect(m_configureLayoutBtn, &QPushButton::clicked, this, [this]() {
if (m_singleBuildingId.has_value()) if (m_singleBuildingId.has_value())
{ {
@@ -151,13 +195,13 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
} }
}); });
connect(m_filterAList, &QListWidget::itemChanged, connect(m_filterAList, &QListWidget::itemChanged,
this, &SelectedBuildingPanel::onSplitterFilterChanged); this, &SelectionPanel::onSplitterFilterChanged);
connect(m_filterBList, &QListWidget::itemChanged, connect(m_filterBList, &QListWidget::itemChanged,
this, &SelectedBuildingPanel::onSplitterFilterChanged); this, &SelectionPanel::onSplitterFilterChanged);
// The field selection renders below the building content and hides itself while // The field selection renders below the building content and hides itself while
// nothing field-side is selected, so it costs no space then. // 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); m_layout->addWidget(m_fieldSelectionPanel);
buildEmpty(); buildEmpty();
@@ -165,12 +209,18 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
registerForEvents(); registerForEvents();
} }
SelectedBuildingPanel::~SelectedBuildingPanel() SelectionPanel::~SelectionPanel()
{ {
unregisterForEvents(); unregisterForEvents();
} }
void SelectedBuildingPanel::onSelectionChanged(const std::vector<BuildingId>& ids) void SelectionPanel::anchorTo(const QRect& bandRect)
{
m_bandRect = bandRect;
updateVisibility();
}
void SelectionPanel::onSelectionChanged(const std::vector<BuildingId>& ids)
{ {
m_selectedBuildingIds = ids; m_selectedBuildingIds = ids;
if (!ids.empty()) if (!ids.empty())
@@ -182,17 +232,92 @@ void SelectedBuildingPanel::onSelectionChanged(const std::vector<BuildingId>& id
rebuild(); rebuild();
} }
void SelectedBuildingPanel::yieldToFieldSelection() void SelectionPanel::yieldToFieldSelection()
{ {
// The mirror image of onSelectionChanged(): a field selection — actors, debris, or // The mirror image of onSelectionChanged(): a field selection — actors, debris, or
// both — supersedes any building selection (REQ-UI-SELECTION-CATEGORIES). An empty // both — supersedes any building selection (REQ-UI-SELECTION-CATEGORIES). An empty
// field selection changes nothing here: the building content, if any, keeps the panel. // field selection leaves the content alone: the building content, if any, keeps the
if (!m_fieldSelectionPanel->hasSelection()) { return; } // panel; it only has to be re-checked for whether anything is left to show at all.
m_selectedBuildingIds.clear(); if (!m_fieldSelectionPanel->hasSelection())
{
updateVisibility();
return;
}
buildEmpty(); 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()) if (m_selectedBuildingIds.empty())
{ {
@@ -208,7 +333,7 @@ void SelectedBuildingPanel::rebuild()
} }
} }
void SelectedBuildingPanel::hideAllWidgets() void SelectionPanel::hideAllWidgets()
{ {
m_titleLabel->hide(); m_titleLabel->hide();
m_recipeSelectButton->hide(); m_recipeSelectButton->hide();
@@ -222,15 +347,20 @@ void SelectedBuildingPanel::hideAllWidgets()
m_buffersLabel->hide(); m_buffersLabel->hide();
} }
void SelectedBuildingPanel::buildEmpty() void SelectionPanel::buildEmpty()
{ {
// Shows nothing for the building category — either because nothing is selected or // Shows nothing for the building category — either because nothing is selected or
// because the field category has taken the panel over. // because the field category has taken the panel over.
m_singleBuildingId = std::nullopt; 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(); hideAllWidgets();
updateVisibility();
} }
void SelectedBuildingPanel::buildSingle(BuildingId id) void SelectionPanel::buildSingle(BuildingId id)
{ {
m_singleBuildingId = id; m_singleBuildingId = id;
hideAllWidgets(); hideAllWidgets();
@@ -341,7 +471,7 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
} }
} }
void SelectedBuildingPanel::refreshSiteProgress(const ConstructionSite* s) void SelectionPanel::refreshSiteProgress(const ConstructionSite* s)
{ {
QString progress; QString progress;
if (s->completesAt == 0) if (s->completesAt == 0)
@@ -369,9 +499,13 @@ void SelectedBuildingPanel::refreshSiteProgress(const ConstructionSite* s)
} }
} }
m_buffersLabel->setText(progress); 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 RecipeDef* recipe = findRecipe(b);
const ShipDef* shipDef = (b->type == BuildingType::Shipyard) 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 // Configure Layout button's visibility; otherwise they stay hidden until the
// building is re-selected (which re-runs buildSingle). // building is re-selected (which re-runs buildSingle).
updateShipyardLayoutWidgets(b->type, b->recipeId, b->shipLayout); 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, BuildingType type,
const std::string& recipeId, const std::string& recipeId,
const std::optional<ShipLayoutConfig>& shipLayout) const std::optional<ShipLayoutConfig>& shipLayout)
@@ -570,24 +708,24 @@ void SelectedBuildingPanel::updateShipyardLayoutWidgets(
m_configureLayoutBtn->show(); 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; } if (b->recipeId.empty()) { return nullptr; }
return m_config->recipes.findRecipeDef(b->recipeId, b->type); 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; } if (id.empty()) { return nullptr; }
return m_config->ships.findShipDef(id); return m_config->ships.findShipDef(id);
} }
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/) void SelectionPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
{ {
refreshSelectionDisplay(RefreshReason::PeriodicTick); refreshSelectionDisplay(RefreshReason::PeriodicTick);
} }
void SelectedBuildingPanel::handleEvent( void SelectionPanel::handleEvent(
std::shared_ptr<const PlayerCommandsAppliedEvent> /*event*/) std::shared_ptr<const PlayerCommandsAppliedEvent> /*event*/)
{ {
// Player commands (e.g. choosing a shipyard schematic) are applied by a // Player commands (e.g. choosing a shipyard schematic) are applied by a
@@ -597,12 +735,18 @@ void SelectedBuildingPanel::handleEvent(
refreshSelectionDisplay(RefreshReason::CommandApplied); 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 // 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 // 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. // 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); const Building* b = findBuilding(m_sim->getFactoryState(), *m_singleBuildingId);
if (b) if (b)
{ {
@@ -636,7 +780,7 @@ void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason)
buildEmpty(); buildEmpty();
} }
void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids) void SelectionPanel::buildMulti(const std::vector<BuildingId>& ids)
{ {
m_singleBuildingId = std::nullopt; m_singleBuildingId = std::nullopt;
m_recipeSelectButton->hide(); m_recipeSelectButton->hide();
@@ -646,6 +790,11 @@ void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
m_filterBLabel->hide(); m_filterBLabel->hide();
m_filterBList->hide(); m_filterBList->hide();
m_buffersLabel->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<BuildingType, int> counts; std::map<BuildingType, int> counts;
for (BuildingId id : ids) for (BuildingId id : ids)
@@ -690,9 +839,11 @@ void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
{ {
m_clearBeltBtn->show(); m_clearBeltBtn->show();
} }
updateVisibility();
} }
void SelectedBuildingPanel::onSelectRecipeClicked() void SelectionPanel::onSelectRecipeClicked()
{ {
if (!m_singleBuildingId.has_value()) if (!m_singleBuildingId.has_value())
{ {
@@ -709,7 +860,7 @@ void SelectedBuildingPanel::onSelectRecipeClicked()
rebuild(); rebuild();
} }
void SelectedBuildingPanel::buildSplitterFilters( void SelectionPanel::buildSplitterFilters(
const std::optional<BeltSystem::SplitterInfo>& info) const std::optional<BeltSystem::SplitterInfo>& info)
{ {
if (!info.has_value()) if (!info.has_value())
@@ -753,7 +904,7 @@ void SelectedBuildingPanel::buildSplitterFilters(
rotationLabel(info->outputB), info->filterB); rotationLabel(info->outputB), info->filterB);
} }
void SelectedBuildingPanel::onSplitterFilterChanged() void SelectionPanel::onSplitterFilterChanged()
{ {
if (!m_singleBuildingId.has_value()) if (!m_singleBuildingId.has_value())
{ {
@@ -796,7 +947,7 @@ void SelectedBuildingPanel::onSplitterFilterChanged()
} }
} }
std::vector<std::string> SelectedBuildingPanel::getAllItemIds() const std::vector<std::string> SelectionPanel::getAllItemIds() const
{ {
std::set<std::string> seen; std::set<std::string> seen;
for (const RecipeDef& recipe : m_config->recipes.recipes) for (const RecipeDef& recipe : m_config->recipes.recipes)
@@ -813,7 +964,7 @@ std::vector<std::string> SelectedBuildingPanel::getAllItemIds() const
return std::vector<std::string>(seen.begin(), seen.end()); return std::vector<std::string>(seen.begin(), seen.end());
} }
void SelectedBuildingPanel::onClearBelt() void SelectionPanel::onClearBelt()
{ {
std::vector<QPoint> tiles; std::vector<QPoint> tiles;
for (BuildingId id : m_selectedBuildingIds) for (BuildingId id : m_selectedBuildingIds)
@@ -837,18 +988,18 @@ void SelectedBuildingPanel::onClearBelt()
} }
} }
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) void SelectionPanel::handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event)
{ {
m_fieldSelectionPanel->setSelectedEntities(event->entities); m_fieldSelectionPanel->setSelectedEntities(event->entities);
yieldToFieldSelection(); yieldToFieldSelection();
} }
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event) void SelectionPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
{ {
onSelectionChanged(event->ids); onSelectionChanged(event->ids);
} }
void SelectedBuildingPanel::handleEvent( void SelectionPanel::handleEvent(
std::shared_ptr<const DebrisSelectionChangedEvent> event) std::shared_ptr<const DebrisSelectionChangedEvent> event)
{ {
// Debris is a field object: it supersedes any building selection but coexists // Debris is a field object: it supersedes any building selection but coexists

View File

@@ -5,6 +5,7 @@
#include <vector> #include <vector>
#include <QPoint> #include <QPoint>
#include <QRect>
#include <QWidget> #include <QWidget>
#include "BeltSystem.h" #include "BeltSystem.h"
@@ -28,6 +29,7 @@ class ShipLayoutPreview;
class QLabel; class QLabel;
class QListWidget; class QListWidget;
class QPushButton; class QPushButton;
class QScrollArea;
class QVBoxLayout; class QVBoxLayout;
// Shows the current selection. The building category (buildings and construction sites) // 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 // 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 // events, forwards the field ones to the child panel, and drops the losing category's
// content. Neither panel touches the other's widgets. // content. Neither panel touches the other's widgets.
class SelectedBuildingPanel : public QWidget, //
public CombinedEventHandler<TickAdvancedEvent, // The panel floats over the game world view rather than occupying a column of its own
PlayerCommandsAppliedEvent, // (REQ-UI-SELECTION-PANEL): it sizes itself to its content, anchors to the right edge of
EntitySelectionChangedEvent, // the band its owner hands it, and hides itself entirely while nothing is selected
SelectionChangedEvent, // (REQ-UI-EMPTY-SELECTION).
DebrisSelectionChangedEvent> class SelectionPanel : public QWidget,
public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent,
EntitySelectionChangedEvent,
SelectionChangedEvent,
DebrisSelectionChangedEvent>
{ {
Q_OBJECT Q_OBJECT
public: public:
SelectedBuildingPanel(Simulation* sim, const GameConfig* config, SelectionPanel(Simulation* sim, const GameConfig* config,
QWidget* parent = nullptr); QWidget* parent = nullptr);
~SelectedBuildingPanel() override; ~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: private:
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override; void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
@@ -78,6 +91,13 @@ private:
void onSelectionChanged(const std::vector<BuildingId>& ids); void onSelectionChanged(const std::vector<BuildingId>& ids);
// Gives the panel to the field category once it has anything selected. // Gives the panel to the field category once it has anything selected.
void yieldToFieldSelection(); 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 refreshSelectionDisplay(RefreshReason reason);
void rebuild(); void rebuild();
void hideAllWidgets(); void hideAllWidgets();
@@ -98,6 +118,15 @@ private:
const GameConfig* m_config; const GameConfig* m_config;
std::vector<BuildingId> m_selectedBuildingIds; std::vector<BuildingId> 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; QVBoxLayout* m_layout;
QLabel* m_titleLabel; QLabel* m_titleLabel;
QPushButton* m_recipeSelectButton; QPushButton* m_recipeSelectButton;