diff --git a/src/lib/core/FloatingPanelPlacement.cpp b/src/lib/core/FloatingPanelPlacement.cpp index a048956..19ebe59 100644 --- a/src/lib/core/FloatingPanelPlacement.cpp +++ b/src/lib/core/FloatingPanelPlacement.cpp @@ -22,3 +22,55 @@ int getAvailableBottomPx(const QRect& band, const std::vector& occupiedRe } 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& 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); +} diff --git a/src/lib/core/FloatingPanelPlacement.h b/src/lib/core/FloatingPanelPlacement.h index 6b24adc..fdae085 100644 --- a/src/lib/core/FloatingPanelPlacement.h +++ b/src/lib/core/FloatingPanelPlacement.h @@ -3,6 +3,7 @@ #include #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, @@ -16,3 +17,27 @@ // 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); + +// 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& occupiedRects, + int marginPx); diff --git a/src/lib/eventsystem/event/CMakeLists.txt b/src/lib/eventsystem/event/CMakeLists.txt index 6774bc8..4bf7f03 100644 --- a/src/lib/eventsystem/event/CMakeLists.txt +++ b/src/lib/eventsystem/event/CMakeLists.txt @@ -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 diff --git a/src/lib/eventsystem/event/SelectionAnchorChangedEvent.h b/src/lib/eventsystem/event/SelectionAnchorChangedEvent.h new file mode 100644 index 0000000..61c0b14 --- /dev/null +++ b/src/lib/eventsystem/event/SelectionAnchorChangedEvent.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +#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; +}; diff --git a/src/test/FloatingPanelPlacementTest.cpp b/src/test/FloatingPanelPlacementTest.cpp index 7abff1e..baa3e69 100644 --- a/src/test/FloatingPanelPlacementTest.cpp +++ b/src/test/FloatingPanelPlacementTest.cpp @@ -66,3 +66,91 @@ TEST_CASE("A widget filling the column leaves nothing", "[layout]") const std::vector 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 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)); +} diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index fb2986f..b16063d 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -15,6 +15,7 @@ SET(HDRS ${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 @@ -48,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 diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 82b9369..f601faf 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -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& buildings, + const std::vector& actors, + const std::vector& 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(anchorRect)); +} + void GameWorldView::mouseMoveEvent(QMouseEvent* event) { const WorldCoordinates coordinates = getCoordinates(); diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 53cae3e..87bedf6 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -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& buildings, + const std::vector& actors, + const std::vector& debris); void stepSpeed(int delta); void placeAtTile(QPoint tile); diff --git a/src/ui/SelectionBounds.cpp b/src/ui/SelectionBounds.cpp new file mode 100644 index 0000000..52c734c --- /dev/null +++ b/src/ui/SelectionBounds.cpp @@ -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(coordinates.getTilePx()), + footprint.height() * static_cast(coordinates.getTilePx())); +} + +} // namespace + + +std::optional 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 getActorWidgetRect(EntityAdmin& admin, + const WorldCoordinates& coordinates, + entt::entity actor) +{ + if (!admin.isValid(actor)) + { + return std::nullopt; + } + if (admin.hasAll(actor)) + { + const StationBodyComponent& body = admin.get(actor); + return getFootprintWidgetRect(coordinates, body.anchor, body.footprint); + } + if (admin.hasAll(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(actor).value); + const qreal extent = static_cast(getShipForwardExtentPx(coordinates)); + return QRectF(center.x() - extent, center.y() - extent, + 2.0 * extent, 2.0 * extent); + } + return std::nullopt; +} + +std::optional getDebrisWidgetRect(EntityAdmin& admin, + const WorldCoordinates& coordinates, + entt::entity debris) +{ + if (!admin.isValid(debris) || !admin.hasAll(debris)) + { + return std::nullopt; + } + const QPointF center = + coordinates.worldToWidget(admin.get(debris).value); + const qreal radius = static_cast(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& buildings, + const std::vector& actors, + const std::vector& 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& 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(); +} diff --git a/src/ui/SelectionBounds.h b/src/ui/SelectionBounds.h new file mode 100644 index 0000000..60c34e6 --- /dev/null +++ b/src/ui/SelectionBounds.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include + +#include + +#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 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 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 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& buildings, + const std::vector& actors, + const std::vector& debris); diff --git a/src/ui/SelectionPanel.cpp b/src/ui/SelectionPanel.cpp index b40cfdf..e584e6a 100644 --- a/src/ui/SelectionPanel.cpp +++ b/src/ui/SelectionPanel.cpp @@ -116,6 +116,18 @@ void SelectionPanel::handleEvent(std::shared_ptr ev rebuildContent(); } +void SelectionPanel::handleEvent( + std::shared_ptr 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 event) { @@ -226,6 +238,15 @@ void SelectionPanel::placeIn(const QRect& viewRect, const std::vector& oc 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 @@ -269,17 +290,26 @@ void SelectionPanel::placeIn(const QRect& viewRect, const std::vector& oc // 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; + // 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; @@ -313,11 +343,8 @@ void SelectionPanel::placeIn(const QRect& viewRect, const std::vector& oc const int panelWidthPx = contentWidthPx + 2 * borderPx; const int panelHeightPx = contentHeightPx + 2 * borderPx; - // 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() + (freeHeightPx - panelHeightPx) / 2, - panelWidthPx, panelHeightPx); + setGeometry(placeBesideAnchor(band, anchorRect, *m_side, + QSize(panelWidthPx, panelHeightPx), + occupiedRects, kMarginPx)); } } diff --git a/src/ui/SelectionPanel.h b/src/ui/SelectionPanel.h index 8982ea6..0bffd28 100644 --- a/src/ui/SelectionPanel.h +++ b/src/ui/SelectionPanel.h @@ -3,6 +3,7 @@ #include #include +#include #include #include "DebrisSelectionChangedEvent.h" @@ -10,7 +11,9 @@ #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" @@ -40,6 +43,7 @@ class SelectionPanel : public QWidget, PlayerCommandsAppliedEvent, EntitySelectionChangedEvent, SelectionChangedEvent, + SelectionAnchorChangedEvent, DebrisSelectionChangedEvent, DebugDrawToggledEvent> { @@ -53,15 +57,16 @@ public: BuildingIconCache* buildingIcons, QWidget* parent = nullptr); ~SelectionPanel() override; - // Sizes the panel to its card and places it in the game world view, right-aligned and - // vertically centered 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). + // 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& occupiedRects) override; 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; void handleEvent(std::shared_ptr event) override; @@ -87,6 +92,16 @@ private: ContentKey m_contentKey; SelectionContent* m_content = nullptr; + // 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 m_side; + // 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; diff --git a/src/ui/WorldRenderer.cpp b/src/ui/WorldRenderer.cpp index b4fcc68..a7e5bd5 100644 --- a/src/ui/WorldRenderer.cpp +++ b/src/ui/WorldRenderer.cpp @@ -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 WorldRenderer::footprintWidgetRect( - const WorldCoordinates& coordinates, BuildingId id) const -{ - std::optional anchor; - std::optional 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(coordinates.getTilePx()), - footprint->height() * static_cast(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 rect = footprintWidgetRect(coordinates, selId); + const std::optional 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)); diff --git a/src/ui/WorldRenderer.h b/src/ui/WorldRenderer.h index d8be56c..babe154 100644 --- a/src/ui/WorldRenderer.h +++ b/src/ui/WorldRenderer.h @@ -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 footprintWidgetRect(const WorldCoordinates& coordinates, - BuildingId id) const; - std::optional entityPosition(entt::entity entity) const; // Non-const only because EntityAdmin's component accessors are; the renderer