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;
};