place the selection panel beside what it describes

This commit is contained in:
2026-08-09 13:45:04 +02:00
parent 7ae5f8c4dc
commit c0b009c548
25 changed files with 789 additions and 182 deletions

View File

@@ -16,6 +16,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h
${CMAKE_CURRENT_SOURCE_DIR}/FloatingPanelPlacement.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.h
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.h
@@ -32,6 +33,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FloatingPanelPlacement.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.cpp

View File

@@ -0,0 +1,76 @@
#include "FloatingPanelPlacement.h"
#include <algorithm>
int getAvailableBottomPx(const QRect& band, const std::vector<QRect>& occupiedRects,
int leftPx, int rightPx, int marginPx)
{
int bottomPx = band.bottom();
for (const QRect& occupied : occupiedRects)
{
if (occupied.isEmpty())
{
continue;
}
// Only what is actually in the way counts: a widget entirely to one side of this
// span is not below it, however tall it is.
if (occupied.right() < leftPx || occupied.left() > rightPx)
{
continue;
}
bottomPx = std::min(bottomPx, occupied.top() - marginPx - 1);
}
return bottomPx;
}
PanelSide chooseSide(const QRect& band, const QRect& anchorRect, int widthPx,
int marginPx)
{
// What each side offers: the gap between the anchor and that edge of the band, less
// the margin the panel keeps from the anchor.
const int roomRightPx = band.right() - anchorRect.right() - marginPx;
const int roomLeftPx = anchorRect.left() - band.left() - marginPx;
if (roomRightPx >= widthPx)
{
return PanelSide::Right;
}
if (roomLeftPx >= widthPx)
{
return PanelSide::Left;
}
// Neither side can hold it without covering the selection, so it goes where it
// covers the least of it.
return (roomRightPx >= roomLeftPx) ? PanelSide::Right : PanelSide::Left;
}
QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide side,
QSize wantedSize, const std::vector<QRect>& occupiedRects,
int marginPx)
{
const int widthPx = std::min(wantedSize.width(), band.width());
// Against the anchor on the chosen side, growing away from it: the edge facing the
// selection is the one that stays put as the panel's content resizes.
int leftPx = (side == PanelSide::Right) ? anchorRect.right() + marginPx + 1
: anchorRect.left() - marginPx - widthPx;
// A panel that does not fit there is pushed back inside the view rather than hanging
// off it, which is what puts it over the selection when neither side had room.
leftPx = std::min(leftPx, band.right() - widthPx + 1);
leftPx = std::max(leftPx, band.left());
// Only the widgets its own column meets can shorten it.
const int bottomPx = getAvailableBottomPx(band, occupiedRects, leftPx,
leftPx + widthPx - 1, marginPx);
const int heightPx =
std::min(wantedSize.height(), std::max(0, bottomPx - band.top() + 1));
// Top-aligned with the anchor, then lifted by however much of it hangs below what is
// free. Never above the band: a panel taller than the space left is capped instead,
// and scrolls.
int topPx = std::max(anchorRect.top(), band.top());
topPx = std::min(topPx, bottomPx - heightPx + 1);
topPx = std::max(topPx, band.top());
return QRect(leftPx, topPx, widthPx, heightPx);
}

View File

@@ -0,0 +1,43 @@
#pragma once
#include <vector>
#include <QRect>
#include <QSize>
// Geometry for the widgets floating over the game world view (REQ-UI-WORLD-SIZE). Their
// owner places them in one ordered pass, each into the space the earlier ones left free,
// and these are the rules they place themselves by. Pure geometry -- no widget is
// involved, which is what lets the rules be tested without a display.
// The lowest bottom edge available to a widget occupying the horizontal span
// [leftPx, rightPx] inside band: the band's own bottom, or marginPx above the topmost
// occupied rectangle whose horizontal extent meets that span. A rectangle beside the
// span is not in the way and does not shorten it (REQ-UI-SELECTION-PANEL,
// REQ-UI-CONTROLS-PANEL). The result is inclusive, as QRect::bottom() is.
int getAvailableBottomPx(const QRect& band, const std::vector<QRect>& occupiedRects,
int leftPx, int rightPx, int marginPx);
// Which side of the selection the panel stands on (REQ-UI-SELECTION-PANEL).
enum class PanelSide
{
Right,
Left
};
// The side a panel widthPx wide takes beside anchorRect: the right of it where it fits
// within band, otherwise the left, and where it fits on neither, whichever side leaves
// more room -- the one case in which the panel ends up over the selection
// (REQ-UI-SELECTION-PANEL). Decided once when the selection starts and kept for as long
// as it lasts, so a card that grows later never flips the panel across the object.
PanelSide chooseSide(const QRect& band, const QRect& anchorRect, int widthPx,
int marginPx);
// Where a panel of wantedSize stands beside anchorRect on the given side: separated from
// it by marginPx and growing away from it, its top edge on the anchor's top edge, pushed
// inside band and above whatever occupies it. The returned height is short of
// wantedSize's when there was not enough room, which is the caller's cue to scroll its
// content (REQ-UI-SELECTION-PANEL).
QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide side,
QSize wantedSize, const std::vector<QRect>& occupiedRects,
int marginPx);

View File

@@ -9,6 +9,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionAnchorChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/GameOverEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/GameResetEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/WinEvent.h
@@ -43,6 +44,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/FloatingLayoutInvalidatedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/PlayerCommandsAppliedEvent.h
PARENT_SCOPE
)

View File

@@ -0,0 +1,12 @@
#pragma once
#include "Event.h"
// Asks the owner of the widgets floating over the game world view to re-run its placement
// pass (see ui/FloatingPanel.h). Published by a floating widget whose content or
// visibility changed: what space that widget may take depends on the ones placed before
// it, so it cannot re-place itself alone (REQ-UI-BUILD-BAR, REQ-UI-CONTROLS-PANEL,
// REQ-UI-SELECTION-PANEL). Carries no payload -- the pass re-reads every widget.
class FloatingLayoutInvalidatedEvent : public Event
{
};

View File

@@ -0,0 +1,25 @@
#pragma once
#include <QRect>
#include "Event.h"
// Where on the screen the selection that is about to be made sits: the bounds of the one
// object selected, or of all of them when the selection starts as a multi-selection
// (REQ-UI-SELECTION-PANEL). The rectangle is in the game world view's own widget
// coordinates.
//
// Published only when a selection *starts* -- a plain click or drag, or an additive one
// onto an empty selection -- and always immediately before the selection itself. Adding
// to a selection publishes nothing, which is what leaves the selection panel where it
// is while the selection grows; and because the rectangle is screen space frozen at that
// moment, scrolling the view or a selected ship flying off does not move the panel
// either.
class SelectionAnchorChangedEvent : public Event
{
public:
explicit SelectionAnchorChangedEvent(QRect rectPx)
: rectPx(rectPx) {}
const QRect rectPx;
};

View File

@@ -14,6 +14,7 @@ add_files(
TunnelCompletionTest.cpp
WorldCoordinatesTest.cpp
WorldCameraTest.cpp
FloatingPanelPlacementTest.cpp
SelectionControllerTest.cpp
BuildModeControllerTest.cpp
ControlActionTest.cpp

View File

@@ -0,0 +1,156 @@
#include "catch.hpp"
#include <vector>
#include <QRect>
#include "FloatingPanelPlacement.h"
// The band every case below places into: 1000x600, so a bottom edge of 599.
static QRect makeBand()
{
return QRect(0, 0, 1000, 600);
}
static const int kMarginPx = 8;
TEST_CASE("With nothing in the way a widget may use the whole band", "[layout]")
{
// REQ-UI-SELECTION-PANEL: the band's own bottom is the limit when no other floating
// widget has been placed yet.
REQUIRE(getAvailableBottomPx(makeBand(), {}, 0, 999, kMarginPx) == 599);
}
TEST_CASE("A widget in the same column pushes the bottom above it", "[layout]")
{
// The build button bar sitting at the bottom center leaves the space above it, less
// the margin kept between the two (REQ-UI-BUILD-BAR).
const std::vector<QRect> occupied = { QRect(400, 520, 200, 72) };
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 350, 650, kMarginPx) == 511);
}
TEST_CASE("A widget beside the column does not shorten it", "[layout]")
{
// The controls panel in the bottom-left corner is not in the way of a panel standing
// at the right edge, however tall it is (REQ-UI-CONTROLS-PANEL).
const std::vector<QRect> occupied = { QRect(0, 100, 200, 499) };
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 700, 999, kMarginPx) == 599);
// Touching columns do count as meeting: the panel starts exactly where the widget
// ends, which is an overlap of one pixel.
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 199, 999, kMarginPx) == 91);
}
TEST_CASE("The topmost widget in the column decides", "[layout]")
{
// Several widgets meet the column: the one that reaches highest is the binding one,
// whatever order they are given in.
const std::vector<QRect> occupied = { QRect(400, 520, 200, 72),
QRect(0, 300, 500, 299),
QRect(900, 560, 100, 40) };
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 450, 550, kMarginPx) == 291);
}
TEST_CASE("An empty rectangle occupies nothing", "[layout]")
{
// A floating widget that is hidden contributes a null rect rather than being left
// out of the pass.
const std::vector<QRect> occupied = { QRect(), QRect(400, 520, 200, 0) };
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 0, 999, kMarginPx) == 599);
}
TEST_CASE("A widget filling the column leaves nothing", "[layout]")
{
// The caller is expected to notice that the space left is not positive rather than
// being handed a floor of its own.
const std::vector<QRect> occupied = { QRect(0, 0, 1000, 600) };
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 0, 999, kMarginPx) == -9);
}
// ---------------------------------------------------------------------------
// Which side of the selection the panel takes
// ---------------------------------------------------------------------------
TEST_CASE("The panel stands to the right of the selection where it fits", "[layout]")
{
// REQ-UI-SELECTION-PANEL: right of the anchor is the first choice.
REQUIRE(chooseSide(makeBand(), QRect(100, 100, 60, 60), 300, kMarginPx)
== PanelSide::Right);
}
TEST_CASE("The panel goes left when the right cannot hold it", "[layout]")
{
// A selection near the right edge leaves 100 px there, not enough for a 300 px
// panel, and the left is wide open.
REQUIRE(chooseSide(makeBand(), QRect(880, 100, 20, 60), 300, kMarginPx)
== PanelSide::Left);
}
TEST_CASE("Fitting on neither side, the panel takes the roomier one", "[layout]")
{
// A bounding box spanning most of the view: 192 px free on the left, 92 on the
// right, and a 300 px panel fits in neither. It covers as little as it can.
REQUIRE(chooseSide(makeBand(), QRect(200, 100, 700, 200), 300, kMarginPx)
== PanelSide::Left);
REQUIRE(chooseSide(makeBand(), QRect(100, 100, 700, 200), 300, kMarginPx)
== PanelSide::Right);
}
// ---------------------------------------------------------------------------
// Where it then stands
// ---------------------------------------------------------------------------
TEST_CASE("The panel sits beside the anchor with its top edges aligned", "[layout]")
{
// REQ-UI-SELECTION-PANEL: separated by the margin, growing away from the selection,
// top edge on the anchor's top edge.
const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 120, 60, 60),
PanelSide::Right, QSize(300, 200), {},
kMarginPx);
REQUIRE(placed == QRect(168, 120, 300, 200));
const QRect placedLeft = placeBesideAnchor(makeBand(), QRect(500, 120, 60, 60),
PanelSide::Left, QSize(300, 200), {},
kMarginPx);
REQUIRE(placedLeft == QRect(192, 120, 300, 200));
}
TEST_CASE("A panel that would hang below the view is lifted", "[layout]")
{
// Top-aligning with a selection low in the view would put the panel's bottom past
// the band, so it rises until it fits rather than overrunning it.
const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 500, 60, 60),
PanelSide::Right, QSize(300, 200), {},
kMarginPx);
REQUIRE(placed == QRect(168, 400, 300, 200));
}
TEST_CASE("A panel standing over another widget rises above it", "[layout]")
{
// The controls panel in the bottom-left is in the way of a panel placed to the left
// of a selection: it clears the top of it by the margin (REQ-UI-CONTROLS-PANEL).
const std::vector<QRect> occupied = { QRect(0, 300, 260, 300) };
const QRect placed = placeBesideAnchor(makeBand(), QRect(500, 250, 60, 60),
PanelSide::Left, QSize(300, 200), occupied,
kMarginPx);
REQUIRE(placed == QRect(192, 92, 300, 200));
}
TEST_CASE("A panel taller than the space left is capped", "[layout]")
{
// Capping is the caller's cue to scroll: it asked for 700 and got what there was.
const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 100, 60, 60),
PanelSide::Right, QSize(300, 700), {},
kMarginPx);
REQUIRE(placed == QRect(168, 0, 300, 600));
}
TEST_CASE("A panel that fits on neither side is pushed inside the view", "[layout]")
{
// The one case where it covers part of the selection (REQ-UI-SELECTION-PANEL): it
// stands as far from the anchor as the band allows, not off the edge of it.
const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 100, 700, 200),
PanelSide::Right, QSize(300, 200), {},
kMarginPx);
REQUIRE(placed == QRect(700, 100, 300, 200));
}

View File

@@ -24,6 +24,7 @@
#include "DisplayName.h"
#include "EventManager.h"
#include "ExitBuilderModeRequestedEvent.h"
#include "FloatingLayoutInvalidatedEvent.h"
#include "IconCaption.h"
#include "InputMapper.h"
#include "ItemIconCache.h"
@@ -270,15 +271,25 @@ BuildButtonBar::~BuildButtonBar()
unregisterForEvents();
}
void BuildButtonBar::anchorTo(const QRect& worldViewRect)
void BuildButtonBar::placeIn(const QRect& viewRect,
const std::vector<QRect>& /*occupiedRects*/)
{
m_viewRect = worldViewRect;
recenter();
}
if (viewRect.isNull())
{
return;
}
// The layout drops hidden buttons from its size hint, but only once it has been
// re-run: an unlock changes which buttons are shown, and Qt would not get around to
// it before the bar is measured here.
layout()->activate();
int BuildButtonBar::getStripHeightPx() const
{
return height() + kBottomMarginPx;
const QSize barSize = sizeHint();
// Centered, except that a bar wider than the view stays flush with its left edge
// rather than hanging off both sides.
const int x = qMax(viewRect.left(),
viewRect.left() + (viewRect.width() - barSize.width()) / 2);
const int y = viewRect.bottom() - kBottomMarginPx - barSize.height() + 1;
setGeometry(QRect(QPoint(x, y), barSize));
}
void BuildButtonBar::clearActiveButton()
@@ -328,29 +339,11 @@ void BuildButtonBar::updateVisibility()
{
m_buttons[i]->setVisible(m_sim->isBuildingUnlocked(m_types[i]));
}
// A hidden button leaves the row, so the bar has to take up its new width and
// re-center on it (REQ-UI-BUILD-BAR).
recenter();
}
void BuildButtonBar::recenter()
{
if (m_viewRect.isNull())
{
return;
}
// The layout drops hidden buttons from its size hint, but only once it has been
// re-run: updateVisibility() calls this straight after setVisible(), before Qt
// would get around to it on its own.
layout()->activate();
const QSize barSize = sizeHint();
// Centered, except that a bar wider than the view stays flush with its left edge
// rather than hanging off both sides.
const int x = qMax(m_viewRect.left(),
m_viewRect.left() + (m_viewRect.width() - barSize.width()) / 2);
const int y = m_viewRect.bottom() - kBottomMarginPx - barSize.height() + 1;
setGeometry(QRect(QPoint(x, y), barSize));
// A hidden button leaves the row, so the bar takes up a new width and has to be
// re-centered on it -- and the widgets that keep clear of the bar have to be placed
// against that new rect too, so the whole pass is re-run (REQ-UI-BUILD-BAR).
EventManager::getInstance()->sendEventImmediately(
std::make_shared<FloatingLayoutInvalidatedEvent>());
}
void BuildButtonBar::onBuildButton(int index)

View File

@@ -15,6 +15,7 @@
#include "BuildingType.h"
#include "DeconstructModeChangedEvent.h"
#include "EventHandler.h"
#include "FloatingPanel.h"
#include "GameConfig.h"
#include "UnlockedBuildingsChangedEvent.h"
@@ -24,10 +25,12 @@ class BuildingIconCache;
class ItemIconCache;
// The build menu: one horizontal row of build buttons floating over the game world
// view (REQ-UI-BUILD-BAR). The bar is sized to its buttons; its owner hands it the
// world view's rect through anchorTo() and it centers itself along that rect's
// bottom edge.
// view (REQ-UI-BUILD-BAR). The bar is sized to its buttons and centers itself along the
// bottom edge of the rect its owner places it in. It is placed first of the floating
// widgets and so takes the space it wants outright: nothing ever moves it aside, and the
// widgets placed after it keep out of its way instead.
class BuildButtonBar : public QWidget,
public FloatingPanel,
public CombinedEventHandler<BuilderModeExitedEvent,
DeconstructModeChangedEvent,
BuildHotkeyPressedEvent,
@@ -45,15 +48,11 @@ public:
QWidget* parent = nullptr);
~BuildButtonBar() override;
// Centers the bar along the bottom edge of the game world view's rect, given in
// the bar's parent coordinates (REQ-UI-BUILD-BAR). The rect is remembered, so a
// 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;
// Centers the bar along the bottom edge of the game world view's rect, given in the
// bar's parent coordinates (REQ-UI-BUILD-BAR). Nothing is occupied yet when the bar
// is placed, so it ignores what it is handed there.
void placeIn(const QRect& viewRect,
const std::vector<QRect>& occupiedRects) override;
void clearActiveButton();
@@ -67,11 +66,6 @@ private:
// unlock state (REQ-LOCK-BUILDING); a locked building type's button is hidden.
void updateVisibility();
// Shrinks the bar to its currently shown buttons and re-centers it in the
// anchored rect (REQ-UI-BUILD-BAR). Does nothing until anchorTo() supplied that
// rect, so the construction-time call is harmless.
void recenter();
void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> event) override;
void handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event) override;
void handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event) override;
@@ -91,5 +85,4 @@ private:
std::map<BuildingType, int> m_costs;
std::optional<std::size_t> m_activeIndex;
QPushButton* m_deconstructButton;
QRect m_viewRect;
};

View File

@@ -12,8 +12,10 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.h
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/FloatingPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionBounds.h
${CMAKE_CURRENT_SOURCE_DIR}/ControlsPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/ControlActionText.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h
@@ -47,6 +49,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionBounds.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ControlsPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ControlActionText.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.cpp

View File

@@ -11,6 +11,9 @@
#include <QVBoxLayout>
#include "ControlActionText.h"
#include "EventManager.h"
#include "FloatingLayoutInvalidatedEvent.h"
#include "FloatingPanelPlacement.h"
#include "GameWorldView.h"
#include "selection/SelectionNames.h"
@@ -159,11 +162,10 @@ ControlsPanel::ControlsPanel(const GameWorldView* view, QWidget* parent)
refresh();
}
void ControlsPanel::anchorTo(const QRect& worldRect, const QRect& buildBarRect)
void ControlsPanel::invalidateLayout()
{
m_worldRect = worldRect;
m_buildBarRect = buildBarRect;
refit();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<FloatingLayoutInvalidatedEvent>());
}
void ControlsPanel::mousePressEvent(QMouseEvent* event)
@@ -174,7 +176,7 @@ void ControlsPanel::mousePressEvent(QMouseEvent* event)
{
m_collapsed = !m_collapsed;
m_scrollArea->setVisible(!m_collapsed);
refit();
invalidateLayout();
}
event->accept();
}
@@ -241,7 +243,7 @@ void ControlsPanel::rebuild(const ControlContext& context)
}
m_scrollArea->setVisible(!m_collapsed);
refit();
invalidateLayout();
}
QString ControlsPanel::getHeadingText(const ControlContext& context) const
@@ -277,9 +279,9 @@ QString ControlsPanel::getHeadingText(const ControlContext& context) const
return name + QStringLiteral(" ") + kHeadingSeparator + QStringLiteral(" ") + detail;
}
void ControlsPanel::refit()
void ControlsPanel::placeIn(const QRect& viewRect, const std::vector<QRect>& occupiedRects)
{
if (m_worldRect.isNull()) { return; }
if (viewRect.isNull()) { return; }
// Rows are torn down and rebuilt wholesale, and a widget added to a layout is only
// shown once that layout runs -- without this the new rows count for nothing and
@@ -290,7 +292,7 @@ void ControlsPanel::refit()
m_rowsLayout->invalidate();
m_rowsLayout->activate();
const QRect band = m_worldRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx);
const QRect band = viewRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx);
if (band.width() <= 0 || band.height() <= 0) { return; }
// Measured from the heading and the rows directly rather than from the panel's own
@@ -314,21 +316,17 @@ void ControlsPanel::refit()
// The bottom-left corner of the view, growing upward as rows are added
// (REQ-UI-CONTROLS-PANEL).
int widthPx = qMin(contentWidthPx + chromePx, band.width());
int heightPx = qMin(wantedHeightPx, band.height());
int topPx = band.bottom() - heightPx + 1;
int widthPx = qMin(contentWidthPx + chromePx, band.width());
// The build button bar is centered and sized to its buttons, so it usually leaves
// this corner free and the panel can share the bottom edge with it. Only when the
// two would actually overlap does the panel rise, clearing the bar's top by the
// same margin it keeps from the view's edges (REQ-UI-BUILD-BAR).
const QRect wanted(band.left(), topPx, widthPx, heightPx);
if (!m_buildBarRect.isNull() && wanted.intersects(m_buildBarRect))
{
const int availablePx = m_buildBarRect.top() - kMarginPx - band.top();
heightPx = qMin(heightPx, qMax(0, availablePx));
topPx = m_buildBarRect.top() - kMarginPx - heightPx;
}
// this corner free and the panel can share the bottom edge with it. Only where the
// two would actually meet does the panel rise, clearing the bar's top by the same
// margin it keeps from the view's edges (REQ-UI-BUILD-BAR). The bar is the only
// widget placed before this one, so it is the only rect that can be in the way.
const int bottomPx = getAvailableBottomPx(band, occupiedRects, band.left(),
band.left() + widthPx - 1, kMarginPx);
int heightPx = qMin(wantedHeightPx, qMax(0, bottomPx - band.top() + 1));
int topPx = bottomPx - heightPx + 1;
// Whatever the rows lost to either cap, they scroll for. The scrollbar needs its
// own width, or it would appear over the labels.

View File

@@ -7,6 +7,7 @@
#include <QWidget>
#include "ControlAction.h"
#include "FloatingPanel.h"
class GameWorldView;
class QLabel;
@@ -29,7 +30,7 @@ class QVBoxLayout;
// while the game is paused, so there is no tick to hang it on either. The rebuild is
// skipped unless the resolved content actually differs, which is a vector of enums to
// compare.
class ControlsPanel : public QWidget
class ControlsPanel : public QWidget, public FloatingPanel
{
Q_OBJECT
@@ -42,10 +43,11 @@ public:
// Places the panel in the bottom-left corner of the game world view. It shares the
// bottom edge with the build button bar rather than clearing its strip, the bar
// being centered and sized to its buttons and so usually leaving the left free; it
// rises above the bar only when the two would otherwise overlap. `buildBarRect` is
// the bar's current geometry in the same coordinates, or a null rect when there is
// none to avoid (REQ-UI-CONTROLS-PANEL, REQ-UI-BUILD-BAR).
void anchorTo(const QRect& worldRect, const QRect& buildBarRect);
// rises above the bar only when the two would otherwise overlap
// (REQ-UI-CONTROLS-PANEL, REQ-UI-BUILD-BAR). The bar is the only thing placed before
// it, so it is the only rect it ever has to rise above.
void placeIn(const QRect& viewRect,
const std::vector<QRect>& occupiedRects) override;
protected:
// Clicking the heading collapses and expands the panel (REQ-UI-CONTROLS-PANEL).
@@ -59,8 +61,8 @@ private:
// The heading's "<name> * <detail>" text for the context, detail omitted when the
// context has none.
QString getHeadingText(const ControlContext& context) const;
// Re-fits the panel to its content within the anchored band.
void refit();
// Asks for the placement pass to be re-run, the panel's own size having changed.
void invalidateLayout();
const GameWorldView* m_view;
@@ -82,9 +84,4 @@ private:
// Survives context changes and simulation restarts; presentation only, never a
// command (REQ-UI-CONTROLS-PANEL).
bool m_collapsed = false;
// The world view the panel sits in, and the build button bar it steps around, both
// in the coordinates of its parent. Null until the owner has anchored it.
QRect m_worldRect;
QRect m_buildBarRect;
};

27
src/ui/FloatingPanel.h Normal file
View File

@@ -0,0 +1,27 @@
#pragma once
#include <vector>
#include <QRect>
// A widget floating over the game world view (REQ-UI-WORLD-SIZE). MainWindow places all
// of them in one ordered pass -- build button bar, then controls panel, then selection
// panel -- and each places itself into the space the earlier ones have not taken. The
// order is the priority the requirements state: the bar never moves for anyone
// (REQ-UI-BUILD-BAR), the controls panel steps around the bar (REQ-UI-CONTROLS-PANEL),
// and keeping clear of both is the selection panel's job (REQ-UI-SELECTION-PANEL).
//
// A widget never re-places itself, because what it may take depends on the widgets placed
// before it. It instead publishes FloatingLayoutInvalidatedEvent whenever its content or
// its visibility changed, and the owner re-runs the whole pass.
class FloatingPanel
{
public:
virtual ~FloatingPanel() = default;
// Sets this widget's own geometry within viewRect, keeping clear of occupiedRects --
// the geometry of every floating widget already placed in this pass, in the same
// coordinates. A widget with nothing to show hides itself and takes no space.
virtual void placeIn(const QRect& viewRect,
const std::vector<QRect>& occupiedRects) = 0;
};

View File

@@ -47,6 +47,8 @@
#include "ItemIconCache.h"
#include "PositionComponent.h"
#include "DebrisSystem.h"
#include "SelectionAnchorChangedEvent.h"
#include "SelectionBounds.h"
#include "SelectionChangedEvent.h"
#include "ShipIdentityComponent.h"
#include "ShipSystem.h"
@@ -1255,6 +1257,7 @@ bool GameWorldView::selectAtPoint(QPoint tile, QVector2D worldPos, bool additive
if (!buildingHit.has_value()) { buildingHit = siteAtTile(tile); }
if (buildingHit.has_value())
{
publishSelectionAnchor(mode, {*buildingHit}, {}, {});
m_selection.selectBuildings({*buildingHit}, mode);
return true;
}
@@ -1262,6 +1265,7 @@ bool GameWorldView::selectAtPoint(QPoint tile, QVector2D worldPos, bool additive
const entt::entity actorHit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
if (actorHit != entt::null)
{
publishSelectionAnchor(mode, {}, {actorHit}, {});
m_selection.selectFieldObjects({actorHit}, {}, mode);
return true;
}
@@ -1269,6 +1273,7 @@ bool GameWorldView::selectAtPoint(QPoint tile, QVector2D worldPos, bool additive
const entt::entity debrisHit = debrisAtWorldPos(m_sim->getAdmin(), worldPos);
if (debrisHit != entt::null)
{
publishSelectionAnchor(mode, {}, {}, {debrisHit});
m_selection.selectFieldObjects({}, {debrisHit}, mode);
return true;
}
@@ -1289,6 +1294,7 @@ void GameWorldView::selectInBox(bool additive)
buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile);
if (!boxIds.empty())
{
publishSelectionAnchor(mode, boxIds, {}, {});
m_selection.selectBuildings(boxIds, mode);
return;
}
@@ -1299,6 +1305,7 @@ void GameWorldView::selectInBox(bool additive)
debrisInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
if (!boxActors.empty() || !boxDebris.empty())
{
publishSelectionAnchor(mode, {}, boxActors, boxDebris);
m_selection.selectFieldObjects(boxActors, boxDebris, mode);
return;
}
@@ -1307,6 +1314,35 @@ void GameWorldView::selectInBox(bool additive)
if (!additive) { m_selection.clearAll(); }
}
void GameWorldView::publishSelectionAnchor(SelectionMode mode,
const std::vector<BuildingId>& buildings,
const std::vector<entt::entity>& actors,
const std::vector<entt::entity>& debris)
{
// An additive gesture onto something already selected is growing that selection, not
// starting one, and the panel stays where it was put (REQ-UI-SELECTION-PANEL).
const bool startsSelection =
(mode == SelectionMode::Replace) || (m_selection.getSelectedBuildings().empty()
&& m_selection.getSelectedActors().empty()
&& m_selection.getSelectedDebris().empty());
if (!startsSelection)
{
return;
}
// Screen space, frozen here: the panel is placed against where the selection is at
// this moment and stays there, however far the view scrolls or the objects move
// afterwards.
const QRect anchorRect = getSelectionWidgetRect(*m_sim, getCoordinates(),
buildings, actors, debris);
if (anchorRect.isNull())
{
return;
}
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionAnchorChangedEvent>(anchorRect));
}
void GameWorldView::mouseMoveEvent(QMouseEvent* event)
{
const WorldCoordinates coordinates = getCoordinates();

View File

@@ -226,6 +226,15 @@ private:
// which is the only case that goes on to start a box drag.
bool selectAtPoint(QPoint tile, QVector2D worldPos, bool additive);
void selectInBox(bool additive);
// Publishes where on the screen the selection about to be made sits, so the
// selection panel can be placed beside it (REQ-UI-SELECTION-PANEL). Called with
// what is about to be selected, immediately before selecting it, and publishes
// nothing unless that selection is starting rather than growing.
void publishSelectionAnchor(
SelectionMode mode,
const std::vector<BuildingId>& buildings,
const std::vector<entt::entity>& actors,
const std::vector<entt::entity>& debris);
void stepSpeed(int delta);
void placeAtTile(QPoint tile);

View File

@@ -172,19 +172,50 @@ void MainWindow::layoutPanels()
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(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()));
// The opposite corner: bottom-left of the view, sharing the bottom edge with the
// bar rather than clearing its whole strip, and rising only if the two would meet.
// Anchored after the bar so the geometry it steps around is the current one
// (REQ-UI-CONTROLS-PANEL).
m_controlsPanel->anchorTo(worldRect, m_buildButtonBar->geometry());
m_dimOverlay->setGeometry(0, 0, totalW, totalH);
// The floating widgets are placed in one ordered pass, each into the space the
// earlier ones have not taken (FloatingPanel.h). The order is the priority the
// requirements state: the build button bar takes what it wants and never moves for
// anyone (REQ-UI-BUILD-BAR), the controls panel steps around the bar
// (REQ-UI-CONTROLS-PANEL), and the selection panel keeps clear of both
// (REQ-UI-SELECTION-PANEL). A widget with nothing to show hides itself in placeIn()
// and takes no space.
//
// Re-entry is refused rather than queued: setGeometry() on a widget in the pass can
// reach code that asks for another pass, and the one already running is about to
// produce the same answer.
if (m_layingOut)
{
return;
}
m_layingOut = true;
const std::vector<QWidget*> floatingWidgets = {
m_buildButtonBar, m_controlsPanel, m_selectionPanel };
const std::vector<FloatingPanel*> floatingPanels = {
m_buildButtonBar, m_controlsPanel, m_selectionPanel };
std::vector<QRect> occupiedRects;
for (std::size_t i = 0; i < floatingPanels.size(); ++i)
{
floatingPanels[i]->placeIn(worldRect, occupiedRects);
if (floatingWidgets[i]->isVisible())
{
occupiedRects.push_back(floatingWidgets[i]->geometry());
}
}
m_layingOut = false;
}
void MainWindow::handleEvent(
std::shared_ptr<const FloatingLayoutInvalidatedEvent> /*event*/)
{
// One of the floating widgets changed size or visibility. What each of them may take
// depends on the ones placed before it, so the answer is the whole pass rather than
// that one widget re-placing itself.
layoutPanels();
}
void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event)

View File

@@ -12,6 +12,7 @@
#include "BuildingId.h"
#include "EscapeMenuRequestedEvent.h"
#include "EventHandler.h"
#include "FloatingLayoutInvalidatedEvent.h"
#include "GameConfig.h"
#include "GameOverEvent.h"
#include "LayoutDialogRequestedEvent.h"
@@ -45,7 +46,8 @@ class MainWindow : public QWidget,
LayoutDialogRequestedEvent,
RecipeSelectionRequestedEvent,
BlueprintSaveRequestedEvent,
BlueprintSelectionRequestedEvent>
BlueprintSelectionRequestedEvent,
FloatingLayoutInvalidatedEvent>
{
Q_OBJECT
@@ -67,6 +69,7 @@ private:
void handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const BlueprintSaveRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const FloatingLayoutInvalidatedEvent> event) override;
// Reloads the game config and visuals.toml from disk (REQ-CFG-RELOAD), shared
// by every restart path. On success the reloaded visuals are applied to this
@@ -85,6 +88,8 @@ private:
// both callers already hold theirs, which is what keeps the dim continuous when a
// confirmed save hands straight over to this dialog (REQ-UI-MODAL-DIM).
void showBlueprintSelectionDialog();
// Places the widgets floating over the game world view, in one ordered pass
// (FloatingPanel.h). Runs on a resize and on every FloatingLayoutInvalidatedEvent.
void layoutPanels();
private:
@@ -109,4 +114,8 @@ private:
std::vector<ShipLayoutBlueprint> m_layoutBlueprints;
std::shared_ptr<ParsedReplay> m_replay; // non-null => view-only playback
// Set while the placement pass runs, so a widget placed in it cannot start a second
// pass from inside the first.
bool m_layingOut = false;
};

115
src/ui/SelectionBounds.cpp Normal file
View File

@@ -0,0 +1,115 @@
#include "SelectionBounds.h"
#include "Building.h"
#include "DebrisComponent.h"
#include "EntityAdmin.h"
#include "FactoryQueries.h"
#include "PositionComponent.h"
#include "Simulation.h"
#include "StationBodyComponent.h"
#include "WorldCoordinates.h"
#include "WorldPrimitives.h"
namespace
{
// The widget rectangle of a footprint anchored at a tile, both kinds of body being
// described the same way.
QRectF getFootprintWidgetRect(const WorldCoordinates& coordinates, QPoint anchor,
QSize footprint)
{
const QPointF topLeft = coordinates.tileToWidget(anchor);
return QRectF(topLeft.x(), topLeft.y(),
footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
}
} // namespace
std::optional<QRectF> getBuildingWidgetRect(const FactoryState& state,
const WorldCoordinates& coordinates,
BuildingId id)
{
if (const Building* building = findBuilding(state, id))
{
return getFootprintWidgetRect(coordinates, building->anchor, building->footprint);
}
if (const ConstructionSite* site = findSite(state, id))
{
return getFootprintWidgetRect(coordinates, site->anchor, site->footprint);
}
return std::nullopt;
}
std::optional<QRectF> getActorWidgetRect(EntityAdmin& admin,
const WorldCoordinates& coordinates,
entt::entity actor)
{
if (!admin.isValid(actor))
{
return std::nullopt;
}
if (admin.hasAll<StationBodyComponent>(actor))
{
const StationBodyComponent& body = admin.get<StationBodyComponent>(actor);
return getFootprintWidgetRect(coordinates, body.anchor, body.footprint);
}
if (admin.hasAll<PositionComponent>(actor))
{
// A ship is a triangle about its center; the square its longest extent fits in
// is what the panel is placed beside.
const QPointF center =
coordinates.worldToWidget(admin.get<PositionComponent>(actor).value);
const qreal extent = static_cast<qreal>(getShipForwardExtentPx(coordinates));
return QRectF(center.x() - extent, center.y() - extent,
2.0 * extent, 2.0 * extent);
}
return std::nullopt;
}
std::optional<QRectF> getDebrisWidgetRect(EntityAdmin& admin,
const WorldCoordinates& coordinates,
entt::entity debris)
{
if (!admin.isValid(debris) || !admin.hasAll<PositionComponent, DebrisComponent>(debris))
{
return std::nullopt;
}
const QPointF center =
coordinates.worldToWidget(admin.get<PositionComponent>(debris).value);
const qreal radius = static_cast<qreal>(getDebrisRadiusPx(coordinates));
return QRectF(center.x() - radius, center.y() - radius, 2.0 * radius, 2.0 * radius);
}
QRect getSelectionWidgetRect(Simulation& sim, const WorldCoordinates& coordinates,
const std::vector<BuildingId>& buildings,
const std::vector<entt::entity>& actors,
const std::vector<entt::entity>& debris)
{
QRectF bounds;
// A null rect unites to nothing of its own, so the first object found sets the box
// and every later one grows it.
auto add = [&bounds](const std::optional<QRectF>& rect)
{
if (rect.has_value())
{
bounds = bounds.isNull() ? *rect : bounds.united(*rect);
}
};
for (BuildingId id : buildings)
{
add(getBuildingWidgetRect(sim.getFactoryState(), coordinates, id));
}
for (entt::entity actor : actors)
{
add(getActorWidgetRect(sim.getAdmin(), coordinates, actor));
}
for (entt::entity piece : debris)
{
add(getDebrisWidgetRect(sim.getAdmin(), coordinates, piece));
}
return bounds.isNull() ? QRect() : bounds.toAlignedRect();
}

50
src/ui/SelectionBounds.h Normal file
View File

@@ -0,0 +1,50 @@
#pragma once
#include <optional>
#include <vector>
#include <QRect>
#include "entt/entity/entity.hpp"
#include "BuildingId.h"
class EntityAdmin;
class Simulation;
class WorldCoordinates;
struct FactoryState;
// Where selectable objects are on the screen. The world renderer draws every selection
// outline from these rectangles, and the selection panel is placed beside the one that
// covers the whole selection (REQ-UI-SELECTION-PANEL).
//
// All of them are in the game world view's own widget coordinates, and all of them are
// only true for the WorldCoordinates they were asked for: the transform is a value built
// per frame or per event, so a rectangle does not survive a scroll or a resize.
// The footprint of a building or of a construction site (REQ-UI-SELECTION-CARD). Null
// when the id names neither, which is what a deconstruction under the caller looks like.
std::optional<QRectF> getBuildingWidgetRect(const FactoryState& state,
const WorldCoordinates& coordinates,
BuildingId id);
// The body of a ship or of a defence station (REQ-UI-ENTITY-CLICK-SELECT): a station's
// footprint, or the square the ship's triangle is drawn in. The admin is taken by
// non-const reference only because EntityAdmin::hasAll() is not const; nothing here
// writes to it.
std::optional<QRectF> getActorWidgetRect(EntityAdmin& admin,
const WorldCoordinates& coordinates,
entt::entity actor);
// The circle a piece of debris is drawn as (REQ-UI-DEBRIS-CLICK-SELECT).
std::optional<QRectF> getDebrisWidgetRect(EntityAdmin& admin,
const WorldCoordinates& coordinates,
entt::entity debris);
// The rectangle covering a whole selection -- one object, or the bounding box of all of
// them (REQ-UI-SELECTION-PANEL). Objects that no longer resolve are skipped; the result
// is null when none of them does.
QRect getSelectionWidgetRect(Simulation& sim, const WorldCoordinates& coordinates,
const std::vector<BuildingId>& buildings,
const std::vector<entt::entity>& actors,
const std::vector<entt::entity>& debris);

View File

@@ -5,6 +5,9 @@
#include <QVBoxLayout>
#include "BuildingIconCache.h"
#include "EventManager.h"
#include "FloatingLayoutInvalidatedEvent.h"
#include "FloatingPanelPlacement.h"
#include "ItemIconCache.h"
#include "Simulation.h"
#include "VisualsConfig.h"
@@ -13,8 +16,8 @@
namespace
{
// Distance kept between the panel and the edges of the band it is anchored to
// (REQ-UI-SELECTION-PANEL).
// Distance kept between the panel and the edges of the game world view, and between it
// and the widgets it steps around (REQ-UI-SELECTION-PANEL).
const int kMarginPx = 8;
// Upper bound on the card width. The panel is content-sized, but several of the cards'
@@ -94,10 +97,10 @@ SelectionPanel::~SelectionPanel()
unregisterForEvents();
}
void SelectionPanel::anchorTo(const QRect& bandRect)
void SelectionPanel::invalidateLayout()
{
m_bandRect = bandRect;
updateVisibility();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<FloatingLayoutInvalidatedEvent>());
}
void SelectionPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
@@ -113,6 +116,18 @@ void SelectionPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> ev
rebuildContent();
}
void SelectionPanel::handleEvent(
std::shared_ptr<const SelectionAnchorChangedEvent> event)
{
// A new selection is starting. Both the anchor and the side are settled against it
// and then left alone for as long as it lasts (REQ-UI-SELECTION-PANEL); the side is
// only reset here, being resolved on the next placement once the card's width is
// known. The rect arrives in the world view's coordinates and is translated when the
// panel is placed, the two widgets being siblings in the same parent.
m_anchorRect = event->rectPx;
m_side.reset();
}
void SelectionPanel::handleEvent(
std::shared_ptr<const EntitySelectionChangedEvent> event)
{
@@ -178,7 +193,7 @@ void SelectionPanel::refreshContent()
}
m_content->refresh();
updateVisibility();
invalidateLayout();
}
void SelectionPanel::rebuildContent()
@@ -208,36 +223,37 @@ void SelectionPanel::rebuildContent()
m_content->show();
m_content->refresh();
}
updateVisibility();
invalidateLayout();
}
void SelectionPanel::updateVisibility()
void SelectionPanel::placeIn(const QRect& viewRect, const std::vector<QRect>& occupiedRects)
{
// 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_content != nullptr) && !m_bandRect.isNull();
setVisible(shouldShow);
if (shouldShow)
// (REQ-UI-EMPTY-SELECTION), leaving the whole game world view visible.
setVisible(m_content != nullptr);
if (m_content == nullptr || viewRect.isNull())
{
refit();
return;
}
}
void SelectionPanel::refit()
{
const QRect band =
m_bandRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx);
const QRect band = viewRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx);
// The anchor is published in the world view's coordinates and this panel is placed in
// its parent's; the two widgets are siblings, so the view's own origin is the whole
// difference. Without an anchor the panel falls back to the top-right corner, by
// standing beside a point just outside that corner -- in practice unreachable, every
// non-empty selection following a click or a drag that publishes one.
const QRect anchorRect =
m_anchorRect.isNull() ? QRect(band.right() + kMarginPx + 1, band.top(), 1, 1)
: m_anchorRect.translated(viewRect.topLeft());
// The panel's border is drawn around the scroll area rather than around the card, so
// it is added to whatever the card 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)
const int borderPx = 1;
const int maxWidthPx = qMin(kMaxContentWidthPx, band.width() - 2 * borderPx);
if (maxWidthPx <= 0 || band.height() <= 2 * borderPx)
{
return;
}
@@ -273,9 +289,35 @@ void SelectionPanel::refit()
// First at the cap, the most room the card can ever get, to learn how wide it
// wants to be; then at that width for the height that follows from it.
int contentWidthPx = qMin(measureAt(maxWidthPx).width(), maxWidthPx);
// Which side of the selection the panel takes is settled on the first placement
// after a new anchor and kept for as long as that selection lasts, so a card that
// grows or shrinks never flips the panel across the object it describes
// (REQ-UI-SELECTION-PANEL). This is the first point at which its width is known.
if (!m_side.has_value())
{
m_side = chooseSide(band, anchorRect, contentWidthPx + 2 * borderPx,
kMarginPx);
}
// How much height there is depends on where the panel ends up standing: of the
// widgets placed before it, only those whose rectangles meet its own column are
// in its way. That column follows from the width just measured, so this cannot be
// settled before it -- and where the scroll bar below widens the panel, the second
// pass settles it again against the wider column. Asking for the whole band's
// height is what makes the answer the most the panel could have there.
const int maxHeightPx =
placeBesideAnchor(band, anchorRect, *m_side,
QSize(contentWidthPx + 2 * borderPx, band.height()),
occupiedRects, kMarginPx).height() - 2 * borderPx;
if (maxHeightPx <= 0)
{
return;
}
int contentHeightPx = measureAt(contentWidthPx).height();
// A card taller than the band is capped there and scrolls
// A card taller than the space left is capped there and scrolls
// (REQ-UI-SELECTION-PANEL). The bar is laid out beside the card, so the panel
// widens by its width to leave the card the width its height was measured for --
// and where the cap does not allow that, the card is measured again at what is
@@ -301,11 +343,8 @@ void SelectionPanel::refit()
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);
setGeometry(placeBesideAnchor(band, anchorRect, *m_side,
QSize(panelWidthPx, panelHeightPx),
occupiedRects, kMarginPx));
}
}

View File

@@ -3,11 +3,17 @@
#include <QRect>
#include <QWidget>
#include <optional>
#include <vector>
#include "DebrisSelectionChangedEvent.h"
#include "DebugDrawToggledEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventHandler.h"
#include "FloatingPanel.h"
#include "FloatingPanelPlacement.h"
#include "PlayerCommandsAppliedEvent.h"
#include "SelectionAnchorChangedEvent.h"
#include "SelectionChangedEvent.h"
#include "TickAdvancedEvent.h"
#include "selection/SelectionContentFactory.h"
@@ -28,14 +34,16 @@ class QVBoxLayout;
// What each card looks like lives in src/ui/selection/.
//
// 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 card, anchors to the right edge of
// the band its owner hands it, and hides itself entirely while nothing is selected
// (REQ-UI-EMPTY-SELECTION).
// (REQ-UI-SELECTION-PANEL): it sizes itself to its card, places itself in what the build
// button bar and the controls panel have left free, and hides itself entirely while
// nothing is selected (REQ-UI-EMPTY-SELECTION).
class SelectionPanel : public QWidget,
public FloatingPanel,
public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent,
EntitySelectionChangedEvent,
SelectionChangedEvent,
SelectionAnchorChangedEvent,
DebrisSelectionChangedEvent,
DebugDrawToggledEvent>
{
@@ -49,14 +57,16 @@ public:
BuildingIconCache* buildingIcons, 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);
// Sizes the panel to its card and places it beside the selection it describes, in
// what the widgets placed before it have left free. Keeping clear of them is entirely
// this panel's job; neither of them ever moves for it (REQ-UI-SELECTION-PANEL,
// REQ-UI-BUILD-BAR, REQ-UI-CONTROLS-PANEL).
void placeIn(const QRect& viewRect,
const std::vector<QRect>& occupiedRects) override;
private:
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionAnchorChangedEvent> event) override;
void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebrisSelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
@@ -69,11 +79,9 @@ private:
void refreshContent();
// Replaces the card with the one the current selection calls for.
void rebuildContent();
// Shows the panel while a card exists and hides it otherwise
// (REQ-UI-EMPTY-SELECTION), re-fitting it to the card while it is shown.
void updateVisibility();
// Re-fits the panel to its card within the anchored band.
void refit();
// Asks for the placement pass to be re-run, the card having changed size or the
// panel having gained or lost its reason to be shown at all.
void invalidateLayout();
SelectionContext m_context;
// Read through m_context by the cards that need it, so a toggle reaches the card on
@@ -84,12 +92,18 @@ private:
ContentKey m_contentKey;
SelectionContent* m_content = nullptr;
// 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;
// Where the current selection was on the screen when it started, in the game world
// view's coordinates, and which side of it the panel took. Both are frozen for as
// long as the selection lasts: the anchor because the panel does not chase a
// scrolling view or a moving ship, the side because a card that grows must not flip
// the panel across the object (REQ-UI-SELECTION-PANEL). The side is resolved on the
// first placement after a new anchor, being the first point at which the panel's
// width is known.
QRect m_anchorRect;
std::optional<PanelSide> m_side;
// Scrolls the card once it outgrows the band (REQ-UI-SELECTION-PANEL). The card is a
// child of m_body, not of the panel itself.
// Scrolls the card once it outgrows the space the panel has (REQ-UI-SELECTION-PANEL).
// The card is a child of m_body, not of the panel itself.
QScrollArea* m_scrollArea;
QWidget* m_body;
QVBoxLayout* m_bodyLayout;

View File

@@ -35,6 +35,7 @@
#include "ProductionRules.h"
#include "RepairBehavior.h"
#include "SalvageScrapBehavior.h"
#include "SelectionBounds.h"
#include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h"
#include "Simulation.h"
@@ -451,30 +452,6 @@ void WorldRenderer::drawBuildings(QPainter& painter, const WorldCoordinates& coo
drawSelectionHighlights(painter, coordinates, frame);
}
std::optional<QRectF> WorldRenderer::footprintWidgetRect(
const WorldCoordinates& coordinates, BuildingId id) const
{
std::optional<QPoint> anchor;
std::optional<QSize> footprint;
if (const Building* b = findBuilding(m_sim.getFactoryState(), id))
{
anchor = b->anchor;
footprint = b->footprint;
}
else if (const ConstructionSite* s = findSite(m_sim.getFactoryState(), id))
{
anchor = s->anchor;
footprint = s->footprint;
}
if (!anchor.has_value() || !footprint.has_value()) { return std::nullopt; }
const QPointF tl = coordinates.tileToWidget(*anchor);
return QRectF(tl.x(), tl.y(),
footprint->width() * static_cast<qreal>(coordinates.getTilePx()),
footprint->height() * static_cast<qreal>(coordinates.getTilePx()));
}
void WorldRenderer::drawSelectionHighlights(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame)
{
@@ -483,7 +460,8 @@ void WorldRenderer::drawSelectionHighlights(QPainter& painter, const WorldCoordi
for (BuildingId selId : frame.selection.getSelectedBuildings())
{
const std::optional<QRectF> rect = footprintWidgetRect(coordinates, selId);
const std::optional<QRectF> rect =
getBuildingWidgetRect(m_sim.getFactoryState(), coordinates, selId);
if (!rect.has_value()) { continue; }
// Outline sits 1px outside the footprint (into adjacent tiles).
painter.drawRect(rect->adjusted(-1, -1, 1, 1));

View File

@@ -139,11 +139,6 @@ private:
BuildingType type, const QRectF& box,
const QColor& fill) const;
// Widget-space rectangle covering a building or construction site's footprint,
// or nullopt if the id resolves to neither. Used by the selection highlight.
std::optional<QRectF> footprintWidgetRect(const WorldCoordinates& coordinates,
BuildingId id) const;
std::optional<QVector2D> entityPosition(entt::entity entity) const;
// Non-const only because EntityAdmin's component accessors are; the renderer