allow to move the selection panel via mouse drag

This commit is contained in:
2026-08-09 22:09:41 +02:00
parent 37a7a7499a
commit 37378e3c1b
8 changed files with 274 additions and 38 deletions

View File

@@ -2,6 +2,39 @@
#include <algorithm>
namespace
{
// The part every placement shares, whatever put the panel where it wants to be: it is
// pushed inside band rather than hanging off it, shortened to what its own column has
// free, and lifted by however much of it hangs below that. wantedLeftPx and wantedTopPx
// are where the panel would stand if nothing were in the way.
QRect fitInBand(const QRect& band, int wantedLeftPx, int wantedTopPx, QSize wantedSize,
const std::vector<QRect>& occupiedRects, int marginPx)
{
const int widthPx = std::min(wantedSize.width(), band.width());
int leftPx = std::min(wantedLeftPx, 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));
// Never above the band: a panel taller than the space left is capped instead, and
// scrolls.
int topPx = std::max(wantedTopPx, band.top());
topPx = std::min(topPx, bottomPx - heightPx + 1);
topPx = std::max(topPx, band.top());
return QRect(leftPx, topPx, widthPx, heightPx);
}
} // namespace
int getAvailableBottomPx(const QRect& band, const std::vector<QRect>& occupiedRects,
int leftPx, int rightPx, int marginPx)
{
@@ -51,26 +84,27 @@ QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide si
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));
// selection is the one that stays put as the panel's content resizes. 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.
const int wantedLeftPx = (side == PanelSide::Right)
? anchorRect.right() + marginPx + 1
: anchorRect.left() - marginPx - widthPx;
// 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);
// free.
return fitInBand(band, wantedLeftPx, anchorRect.top(), wantedSize, occupiedRects,
marginPx);
}
QRect placeAtDesiredTopLeft(const QRect& band, const QPoint& desiredTopLeftPx,
QSize wantedSize, const std::vector<QRect>& occupiedRects,
int marginPx)
{
// Where the player dropped it, resolved by the same rules as any other placement
// (REQ-UI-SELECTION-PANEL-DRAG). Nothing here is written back to the desired point:
// a panel lifted above the build button bar returns to where it was dropped as soon
// as the bar stops meeting its column.
return fitInBand(band, desiredTopLeftPx.x(), desiredTopLeftPx.y(), wantedSize,
occupiedRects, marginPx);
}

View File

@@ -2,6 +2,7 @@
#include <vector>
#include <QPoint>
#include <QRect>
#include <QSize>
@@ -41,3 +42,13 @@ PanelSide chooseSide(const QRect& band, const QRect& anchorRect, int widthPx,
QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide side,
QSize wantedSize, const std::vector<QRect>& occupiedRects,
int marginPx);
// Where a panel of wantedSize stands once the player has dragged it to desiredTopLeftPx:
// at that point, by the same rules that place it beside a selection -- pushed inside band,
// lifted above whatever occupies its column, and capped in height where that leaves too
// little room (REQ-UI-SELECTION-PANEL-DRAG). The desired point is an input only: it is
// never corrected and handed back, which is what lets the caller keep it exactly as
// dropped and return to it once the room is there again.
QRect placeAtDesiredTopLeft(const QRect& band, const QPoint& desiredTopLeftPx,
QSize wantedSize, const std::vector<QRect>& occupiedRects,
int marginPx);

View File

@@ -154,3 +154,78 @@ TEST_CASE("A panel that fits on neither side is pushed inside the view", "[layou
kMarginPx);
REQUIRE(placed == QRect(700, 100, 300, 200));
}
// ---------------------------------------------------------------------------
// Where it stands once the player has dragged it (REQ-UI-SELECTION-PANEL-DRAG)
// ---------------------------------------------------------------------------
TEST_CASE("A dragged panel stands where it was dropped", "[layout]")
{
// With room for it there, the desired top-left corner is the answer: nothing about
// the selection it describes is consulted any more.
const QRect placed = placeAtDesiredTopLeft(makeBand(), QPoint(420, 180),
QSize(300, 200), {}, kMarginPx);
REQUIRE(placed == QRect(420, 180, 300, 200));
}
TEST_CASE("A panel dragged past the view's edges is pushed back inside", "[layout]")
{
// Dropped off the right edge and below the bottom, it is brought back within the
// band, as an anchored panel is.
REQUIRE(placeAtDesiredTopLeft(makeBand(), QPoint(900, 500), QSize(300, 200), {},
kMarginPx)
== QRect(700, 400, 300, 200));
// And off the left edge and above the top, the other way.
REQUIRE(placeAtDesiredTopLeft(makeBand(), QPoint(-120, -60), QSize(300, 200), {},
kMarginPx)
== QRect(0, 0, 300, 200));
}
TEST_CASE("A dragged panel rises above the widget it was dropped over", "[layout]")
{
// The player drops it over the build button bar; it lands above the bar's top by the
// margin instead, the bar staying where it is (REQ-UI-BUILD-BAR).
const std::vector<QRect> occupied = { QRect(400, 520, 200, 72) };
const QRect placed = placeAtDesiredTopLeft(makeBand(), QPoint(450, 480),
QSize(300, 200), occupied, kMarginPx);
REQUIRE(placed == QRect(450, 312, 300, 200));
}
TEST_CASE("A widget beside a dragged panel's column does not move it", "[layout]")
{
// Only what the panel's own column meets is in its way: the controls panel in the
// bottom-left corner leaves a panel dropped at the right edge alone
// (REQ-UI-CONTROLS-PANEL).
const std::vector<QRect> occupied = { QRect(0, 300, 260, 300) };
const QRect placed = placeAtDesiredTopLeft(makeBand(), QPoint(600, 380),
QSize(300, 200), occupied, kMarginPx);
REQUIRE(placed == QRect(600, 380, 300, 200));
}
TEST_CASE("A dragged panel too tall for the room left is capped", "[layout]")
{
// Capping is the caller's cue to scroll here too: a card asking for 700 px over a bar
// that leaves 512 px of band gets what there is, from the top of the band.
const std::vector<QRect> occupied = { QRect(400, 520, 200, 72) };
const QRect placed = placeAtDesiredTopLeft(makeBand(), QPoint(450, 100),
QSize(300, 700), occupied, kMarginPx);
REQUIRE(placed == QRect(450, 0, 300, 512));
}
TEST_CASE("A dragged panel returns to where it was dropped", "[layout]")
{
// The resolution never writes back to the desired position: a panel lifted above the
// build button bar goes back to the point the player dropped it at as soon as the bar
// stops meeting its column -- here because the bar's button set shrank.
const QPoint desired(450, 380);
const std::vector<QRect> wideBar = { QRect(400, 520, 200, 72) };
REQUIRE(placeAtDesiredTopLeft(makeBand(), desired, QSize(300, 200), wideBar,
kMarginPx)
== QRect(450, 312, 300, 200));
const std::vector<QRect> narrowBar = { QRect(400, 520, 40, 72) };
REQUIRE(placeAtDesiredTopLeft(makeBand(), desired, QSize(300, 200), narrowBar,
kMarginPx)
== QRect(450, 380, 300, 200));
}

View File

@@ -1,5 +1,6 @@
#include "SelectionPanel.h"
#include <QMouseEvent>
#include <QScrollArea>
#include <QScrollBar>
#include <QVBoxLayout>
@@ -103,6 +104,56 @@ void SelectionPanel::invalidateLayout()
std::make_shared<FloatingLayoutInvalidatedEvent>());
}
void SelectionPanel::mousePressEvent(QMouseEvent* event)
{
// Only the card's header moves the panel (REQ-UI-SELECTION-PANEL-DRAG). Presses on
// the header's labels arrive here by ordinary propagation, none of them being a
// widget that accepts mouse events, and carry a position in this panel's coordinates.
if (event->button() == Qt::LeftButton && m_content != nullptr)
{
QWidget* header = m_content->getHeaderWidget();
QRect headerRect(header->mapTo(this, QPoint(0, 0)), header->size());
// A card long enough to scroll can have its header scrolled out of sight; what is
// hidden is not a handle, and the pixels it would claim show other parts of the
// card.
QWidget* viewport = m_scrollArea->viewport();
headerRect &= QRect(viewport->mapTo(this, QPoint(0, 0)), viewport->size());
if (headerRect.contains(event->pos()))
{
m_dragGrabOffsetPx = event->pos();
}
}
// Accepted whether or not it started a drag: no mouse event over the panel reaches
// the game world (REQ-UI-SELECTION-PANEL).
event->accept();
}
void SelectionPanel::mouseMoveEvent(QMouseEvent* event)
{
if (m_dragGrabOffsetPx.has_value() && parentWidget() != nullptr)
{
// Read from the cursor's position on the screen rather than from the panel's own
// coordinates, which move under the cursor as the drag places the panel again.
const QPoint topLeftPx =
parentWidget()->mapFromGlobal(event->globalPos()) - *m_dragGrabOffsetPx;
m_desiredTopLeftPx = topLeftPx - m_viewOriginPx;
// Where the panel actually lands follows from the desired position by the
// ordinary rules, the widgets it steps around included (REQ-UI-SELECTION-PANEL-DRAG).
invalidateLayout();
}
event->accept();
}
void SelectionPanel::mouseReleaseEvent(QMouseEvent* event)
{
// Qt's implicit grab has kept the drag alive while the cursor was outside the panel
// or outside the view; it ends here, wherever the button was let go
// (REQ-UI-SELECTION-PANEL-DRAG). The desired position stays as dropped.
m_dragGrabOffsetPx.reset();
event->accept();
}
void SelectionPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
{
m_request.buildings = event->ids;
@@ -126,6 +177,13 @@ void SelectionPanel::handleEvent(
// panel is placed, the two widgets being siblings in the same parent.
m_anchorRect = event->rectPx;
m_side.reset();
// A position the player dragged the panel to belongs to the selection it was set in.
// A new selection places the panel anew against its own anchor
// (REQ-UI-SELECTION-PANEL-DRAG); this event is published only when one starts, and
// not when an existing selection is added to or reduced, which is exactly the scope
// the dragged position keeps.
m_desiredTopLeftPx.reset();
m_dragGrabOffsetPx.reset();
}
void SelectionPanel::handleEvent(
@@ -238,6 +296,11 @@ void SelectionPanel::placeIn(const QRect& viewRect, const std::vector<QRect>& oc
const QRect band = viewRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx);
// What the view's coordinates and this panel's differ by, kept for the drag gesture,
// which learns of the cursor in the latter and stores its result in the former
// (REQ-UI-SELECTION-PANEL-DRAG).
m_viewOriginPx = viewRect.topLeft();
// 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
@@ -278,6 +341,22 @@ void SelectionPanel::placeIn(const QRect& viewRect, const std::vector<QRect>& oc
return m_body->sizeHint();
};
// Where a panel of that size stands: beside the selection, or at the position the
// player dragged the panel to, which replaces both the anchor and the side for the
// rest of the selection (REQ-UI-SELECTION-PANEL-DRAG). Either way the result is
// resolved against the view's edges and the widgets placed before this one, so a
// dragged panel steps around them exactly as an anchored one does.
auto solve = [&](QSize wantedSize) -> QRect
{
if (m_desiredTopLeftPx.has_value())
{
return placeAtDesiredTopLeft(band, *m_desiredTopLeftPx + viewRect.topLeft(),
wantedSize, occupiedRects, kMarginPx);
}
return placeBesideAnchor(band, anchorRect, *m_side, wantedSize, occupiedRects,
kMarginPx);
};
// Run twice. Parts of a card report an unstyled size until the style has actually
// reached them, which for a freshly built card happens during the first round of
// measuring; the second round then measures a card that is fully laid out and
@@ -307,9 +386,8 @@ void SelectionPanel::placeIn(const QRect& viewRect, const std::vector<QRect>& oc
// 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;
solve(QSize(contentWidthPx + 2 * borderPx, band.height())).height()
- 2 * borderPx;
if (maxHeightPx <= 0)
{
return;
@@ -343,8 +421,6 @@ void SelectionPanel::placeIn(const QRect& viewRect, const std::vector<QRect>& oc
const int panelWidthPx = contentWidthPx + 2 * borderPx;
const int panelHeightPx = contentHeightPx + 2 * borderPx;
setGeometry(placeBesideAnchor(band, anchorRect, *m_side,
QSize(panelWidthPx, panelHeightPx),
occupiedRects, kMarginPx));
setGeometry(solve(QSize(panelWidthPx, panelHeightPx)));
}
}

View File

@@ -1,5 +1,6 @@
#pragma once
#include <QPoint>
#include <QRect>
#include <QWidget>
@@ -25,6 +26,7 @@ class BuildingIconCache;
class ItemIconCache;
class SelectionContent;
class Simulation;
class QMouseEvent;
class QScrollArea;
class QVBoxLayout;
@@ -64,6 +66,14 @@ public:
void placeIn(const QRect& viewRect,
const std::vector<QRect>& occupiedRects) override;
protected:
// The panel is moved by dragging its card header (REQ-UI-SELECTION-PANEL-DRAG). Every
// other mouse event over the panel is swallowed here so that none of them reaches the
// game world beneath (REQ-UI-SELECTION-PANEL).
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
void mouseReleaseEvent(QMouseEvent* event) override;
private:
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionAnchorChangedEvent> event) override;
@@ -98,10 +108,23 @@ private:
// 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.
// width is known. Dragging the panel supersedes the pair for the rest of the
// selection (REQ-UI-SELECTION-PANEL-DRAG).
QRect m_anchorRect;
std::optional<PanelSide> m_side;
// Where the player dragged the panel, in the game world view's coordinates, and the
// cursor's offset within the panel while a drag is running
// (REQ-UI-SELECTION-PANEL-DRAG). The desired position is kept exactly as dropped:
// resolving it against the view's edges and the widgets the panel steps around never
// writes back to it, so the panel returns to it once the room is there again. It
// lasts as long as the selection it was set in.
std::optional<QPoint> m_desiredTopLeftPx;
std::optional<QPoint> m_dragGrabOffsetPx;
// Origin of the view the panel was last placed in, which is what the two coordinate
// systems differ by -- the panel is a sibling of the view, not a child of it.
QPoint m_viewOriginPx;
// 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;

View File

@@ -50,26 +50,30 @@ SelectionContent::SelectionContent(const SelectionContext& context,
// Header: identity symbol, name, and the right slot pushed to the far edge
// (REQ-UI-SELECTION-CARD).
QWidget* header = new QWidget(this);
QHBoxLayout* headerLayout = new QHBoxLayout(header);
m_header = new QWidget(this);
// The header doubles as the panel's drag handle (REQ-UI-SELECTION-PANEL-DRAG); the
// cursor says so, as the controls panel's heading does for the click that collapses
// it. The gesture itself belongs to SelectionPanel, which owns the placement.
m_header->setCursor(Qt::SizeAllCursor);
QHBoxLayout* headerLayout = new QHBoxLayout(m_header);
headerLayout->setContentsMargins(0, 0, 0, 0);
headerLayout->setSpacing(kHeaderSpacingPx);
m_symbolLabel = new QLabel(header);
m_symbolLabel = new QLabel(m_header);
m_symbolLabel->hide();
m_nameLabel = new QLabel(header);
m_nameLabel = new QLabel(m_header);
QFont nameFont = m_nameLabel->font();
nameFont.setBold(true);
m_nameLabel->setFont(nameFont);
m_statusPill = new StatusPill(header);
m_statusPill = new StatusPill(m_header);
headerLayout->addWidget(m_symbolLabel);
headerLayout->addWidget(m_nameLabel);
headerLayout->addStretch(1);
headerLayout->addWidget(m_statusPill);
cardLayout->addWidget(header);
cardLayout->addWidget(m_header);
m_configurationGroup = new QWidget(this);
QVBoxLayout* configurationLayout = new QVBoxLayout(m_configurationGroup);

View File

@@ -45,6 +45,11 @@ public:
// SelectionPanel owns that decision.
void refresh();
// The card's header (REQ-UI-SELECTION-CARD), which is also the handle the panel is
// dragged by (REQ-UI-SELECTION-PANEL-DRAG). The panel hit-tests against it; the card
// itself does nothing with the gesture.
QWidget* getHeaderWidget() const { return m_header; }
protected:
// constructionSiteId is set only while the card shows a construction site, in which
// case this base builds and drives the construction section in place of whatever the
@@ -91,6 +96,7 @@ private:
// Set while this card shows a construction site rather than a finished object.
std::optional<BuildingId> m_siteId;
QWidget* m_header;
QLabel* m_symbolLabel;
QLabel* m_nameLabel;
StatusPill* m_statusPill;