diff --git a/src/lib/core/CMakeLists.txt b/src/lib/core/CMakeLists.txt index 8526bc2..53379a7 100644 --- a/src/lib/core/CMakeLists.txt +++ b/src/lib/core/CMakeLists.txt @@ -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 diff --git a/src/lib/core/FloatingPanelPlacement.cpp b/src/lib/core/FloatingPanelPlacement.cpp new file mode 100644 index 0000000..a048956 --- /dev/null +++ b/src/lib/core/FloatingPanelPlacement.cpp @@ -0,0 +1,24 @@ +#include "FloatingPanelPlacement.h" + +#include + +int getAvailableBottomPx(const QRect& band, const std::vector& 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; +} diff --git a/src/lib/core/FloatingPanelPlacement.h b/src/lib/core/FloatingPanelPlacement.h new file mode 100644 index 0000000..6b24adc --- /dev/null +++ b/src/lib/core/FloatingPanelPlacement.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include + +// 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& occupiedRects, + int leftPx, int rightPx, int marginPx); diff --git a/src/lib/eventsystem/event/CMakeLists.txt b/src/lib/eventsystem/event/CMakeLists.txt index a42245d..6774bc8 100644 --- a/src/lib/eventsystem/event/CMakeLists.txt +++ b/src/lib/eventsystem/event/CMakeLists.txt @@ -43,6 +43,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 ) diff --git a/src/lib/eventsystem/event/FloatingLayoutInvalidatedEvent.h b/src/lib/eventsystem/event/FloatingLayoutInvalidatedEvent.h new file mode 100644 index 0000000..a2cab8a --- /dev/null +++ b/src/lib/eventsystem/event/FloatingLayoutInvalidatedEvent.h @@ -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 +{ +}; diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 2c8d4bb..452db25 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -14,6 +14,7 @@ add_files( TunnelCompletionTest.cpp WorldCoordinatesTest.cpp WorldCameraTest.cpp + FloatingPanelPlacementTest.cpp SelectionControllerTest.cpp BuildModeControllerTest.cpp ControlActionTest.cpp diff --git a/src/test/FloatingPanelPlacementTest.cpp b/src/test/FloatingPanelPlacementTest.cpp new file mode 100644 index 0000000..7abff1e --- /dev/null +++ b/src/test/FloatingPanelPlacementTest.cpp @@ -0,0 +1,68 @@ +#include "catch.hpp" + +#include + +#include + +#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 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 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 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 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 occupied = { QRect(0, 0, 1000, 600) }; + REQUIRE(getAvailableBottomPx(makeBand(), occupied, 0, 999, kMarginPx) == -9); +} diff --git a/src/ui/BuildButtonBar.cpp b/src/ui/BuildButtonBar.cpp index 0617dd6..5a30ecd 100644 --- a/src/ui/BuildButtonBar.cpp +++ b/src/ui/BuildButtonBar.cpp @@ -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& /*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()); } void BuildButtonBar::onBuildButton(int index) diff --git a/src/ui/BuildButtonBar.h b/src/ui/BuildButtonBar.h index 7ac55a4..61d7f73 100644 --- a/src/ui/BuildButtonBar.h +++ b/src/ui/BuildButtonBar.h @@ -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& 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 event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; @@ -91,5 +85,4 @@ private: std::map m_costs; std::optional m_activeIndex; QPushButton* m_deconstructButton; - QRect m_viewRect; }; diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 0e46eb4..fb2986f 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -12,6 +12,7 @@ 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}/ControlsPanel.h diff --git a/src/ui/ControlsPanel.cpp b/src/ui/ControlsPanel.cpp index 1da7231..8395d33 100644 --- a/src/ui/ControlsPanel.cpp +++ b/src/ui/ControlsPanel.cpp @@ -11,6 +11,9 @@ #include #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()); } 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& 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. diff --git a/src/ui/ControlsPanel.h b/src/ui/ControlsPanel.h index 5195c76..25cde4f 100644 --- a/src/ui/ControlsPanel.h +++ b/src/ui/ControlsPanel.h @@ -7,6 +7,7 @@ #include #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& occupiedRects) override; protected: // Clicking the heading collapses and expands the panel (REQ-UI-CONTROLS-PANEL). @@ -59,8 +61,8 @@ private: // The heading's " * " 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; }; diff --git a/src/ui/FloatingPanel.h b/src/ui/FloatingPanel.h new file mode 100644 index 0000000..07a62ab --- /dev/null +++ b/src/ui/FloatingPanel.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include + +// 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& occupiedRects) = 0; +}; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index dac996b..67ad1af 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -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 floatingWidgets = { + m_buildButtonBar, m_controlsPanel, m_selectionPanel }; + const std::vector floatingPanels = { + m_buildButtonBar, m_controlsPanel, m_selectionPanel }; + + std::vector 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 /*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 event) diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 4a0e249..c34d225 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -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 event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr 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 m_layoutBlueprints; std::shared_ptr 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; }; diff --git a/src/ui/SelectionPanel.cpp b/src/ui/SelectionPanel.cpp index 59d7b0a..b40cfdf 100644 --- a/src/ui/SelectionPanel.cpp +++ b/src/ui/SelectionPanel.cpp @@ -5,6 +5,9 @@ #include #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()); } void SelectionPanel::handleEvent(std::shared_ptr event) @@ -178,7 +181,7 @@ void SelectionPanel::refreshContent() } m_content->refresh(); - updateVisibility(); + invalidateLayout(); } void SelectionPanel::rebuildContent() @@ -208,36 +211,28 @@ void SelectionPanel::rebuildContent() m_content->show(); m_content->refresh(); } - updateVisibility(); + invalidateLayout(); } -void SelectionPanel::updateVisibility() +void SelectionPanel::placeIn(const QRect& viewRect, const std::vector& 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 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 +268,26 @@ 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); + + // How much height there is depends on where the panel stands: of the widgets + // placed before it, only those whose rectangles meet its own column are in its + // way (REQ-UI-SELECTION-PANEL). Its 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. + const int leftPx = + band.right() - (contentWidthPx + 2 * borderPx) + 1; + const int bottomPx = getAvailableBottomPx(band, occupiedRects, leftPx, + band.right(), kMarginPx); + const int freeHeightPx = bottomPx - band.top() + 1; + const int maxHeightPx = freeHeightPx - 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 +313,11 @@ 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). + // Right-aligned in the band and centered vertically on the free part of it, so + // the panel sits above whatever the widgets placed before it are occupying rather + // than over them (REQ-UI-SELECTION-PANEL). setGeometry(band.right() - panelWidthPx + 1, - band.top() + (band.height() - panelHeightPx) / 2, + band.top() + (freeHeightPx - panelHeightPx) / 2, panelWidthPx, panelHeightPx); } } diff --git a/src/ui/SelectionPanel.h b/src/ui/SelectionPanel.h index e6a2b35..8982ea6 100644 --- a/src/ui/SelectionPanel.h +++ b/src/ui/SelectionPanel.h @@ -3,10 +3,13 @@ #include #include +#include + #include "DebrisSelectionChangedEvent.h" #include "DebugDrawToggledEvent.h" #include "EntitySelectionChangedEvent.h" #include "EventHandler.h" +#include "FloatingPanel.h" #include "PlayerCommandsAppliedEvent.h" #include "SelectionChangedEvent.h" #include "TickAdvancedEvent.h" @@ -28,10 +31,11 @@ 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& occupiedRects) override; private: void handleEvent(std::shared_ptr event) override; @@ -69,11 +74,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 +87,8 @@ 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; - - // 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;