The panel was anchored to the right edge of the view, which is nowhere near whatever the player just clicked. It now stands beside the selection: right of it where it fits, otherwise left, otherwise the roomier side pushed inside the view -- the one case where it covers part of what it describes. Nothing told the panel where the selection was. The selection events carry ids only, and the mode that separates a fresh selection from an expanded one is consumed inside SelectionController before they are built, so the view now publishes the selection's screen bounds itself, immediately before selecting and only when the selection is starting. Freezing that rectangle is what holds the panel still: it does not chase a scrolling view, a ship flying off, or a selection being added to. Only the panel's own size still moves it, and even then it keeps its side and the edge facing the selection. The rectangles come from what the renderer was already computing for the selection outlines, now shared rather than duplicated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
351 lines
15 KiB
C++
351 lines
15 KiB
C++
#include "SelectionPanel.h"
|
|
|
|
#include <QScrollArea>
|
|
#include <QScrollBar>
|
|
#include <QVBoxLayout>
|
|
|
|
#include "BuildingIconCache.h"
|
|
#include "EventManager.h"
|
|
#include "FloatingLayoutInvalidatedEvent.h"
|
|
#include "FloatingPanelPlacement.h"
|
|
#include "ItemIconCache.h"
|
|
#include "Simulation.h"
|
|
#include "VisualsConfig.h"
|
|
#include "selection/SelectionContent.h"
|
|
|
|
namespace
|
|
{
|
|
|
|
// 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'
|
|
// widgets have no natural width of their own -- the word-wrapped summary labels grow
|
|
// without limit, and a QListWidget asks for 256 px whatever it holds -- so the width is
|
|
// capped and the labels wrap at the cap. 320 px is the width the former side panel
|
|
// column had at the default window size.
|
|
const int kMaxContentWidthPx = 320;
|
|
|
|
// Padding between the panel's border and the card inside it.
|
|
const int kCardMarginPx = 8;
|
|
|
|
} // namespace
|
|
|
|
|
|
SelectionPanel::SelectionPanel(Simulation* sim, const GameConfig* config,
|
|
const VisualsConfig* visuals, ItemIconCache* itemIcons,
|
|
BuildingIconCache* buildingIcons, QWidget* parent)
|
|
: QWidget(parent)
|
|
{
|
|
m_context.sim = sim;
|
|
m_context.config = config;
|
|
m_context.visuals = visuals;
|
|
m_context.itemIcons = itemIcons;
|
|
m_context.buildingIcons = buildingIcons;
|
|
m_context.debugDrawEnabled = &m_debugDrawEnabled;
|
|
|
|
// The panel floats over the rendered world rather than sitting in a column, so it
|
|
// brings its own opaque background to stay legible over any world content
|
|
// (REQ-UI-SELECTION-PANEL). Palette colors match the build button bar's chrome; like
|
|
// it, this is widget chrome rather than world rendering, so it is deliberately not a
|
|
// visuals.toml color. The class scoped selector keeps the border on the panel itself
|
|
// rather than cascading onto its child widgets.
|
|
setAttribute(Qt::WA_StyledBackground, true);
|
|
setStyleSheet(QStringLiteral(
|
|
"SelectionPanel { background-color: palette(window);"
|
|
" border: 1px solid palette(mid); border-radius: 4px; }"));
|
|
|
|
// A card taller than the band scrolls rather than overrunning it
|
|
// (REQ-UI-SELECTION-PANEL). The viewport is transparent so the panel's own rounded
|
|
// chrome shows through, and horizontal scrolling is off because the width always
|
|
// follows the content.
|
|
m_body = new QWidget(this);
|
|
m_scrollArea = new QScrollArea(this);
|
|
m_scrollArea->setFrameShape(QFrame::NoFrame);
|
|
m_scrollArea->setWidgetResizable(true);
|
|
m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
|
// The panel works out for itself whether the card fits the band, and sizes itself to
|
|
// leave room for the bar when it does not (REQ-UI-SELECTION-PANEL), so refit() sets
|
|
// this policy rather than leaving the scroll area to decide. Asked to decide, it
|
|
// shows a bar the moment the card is momentarily larger than the viewport -- which
|
|
// happens while the card is being measured -- and does not take it back when the
|
|
// range turns out to be empty.
|
|
m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
|
m_scrollArea->viewport()->setAutoFillBackground(false);
|
|
m_body->setAutoFillBackground(false);
|
|
m_scrollArea->setWidget(m_body);
|
|
|
|
QVBoxLayout* outerLayout = new QVBoxLayout(this);
|
|
outerLayout->setContentsMargins(0, 0, 0, 0);
|
|
outerLayout->setSpacing(0);
|
|
outerLayout->addWidget(m_scrollArea);
|
|
|
|
m_bodyLayout = new QVBoxLayout(m_body);
|
|
m_bodyLayout->setContentsMargins(kCardMarginPx, kCardMarginPx,
|
|
kCardMarginPx, kCardMarginPx);
|
|
m_bodyLayout->setSpacing(0);
|
|
m_bodyLayout->setAlignment(Qt::AlignTop);
|
|
|
|
hide();
|
|
|
|
registerForEvents();
|
|
}
|
|
|
|
SelectionPanel::~SelectionPanel()
|
|
{
|
|
unregisterForEvents();
|
|
}
|
|
|
|
void SelectionPanel::invalidateLayout()
|
|
{
|
|
EventManager::getInstance()->sendEventImmediately(
|
|
std::make_shared<FloatingLayoutInvalidatedEvent>());
|
|
}
|
|
|
|
void SelectionPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
|
|
{
|
|
m_request.buildings = event->ids;
|
|
if (!m_request.buildings.empty())
|
|
{
|
|
// A building selection is exclusive: it supersedes any field selection -- actors
|
|
// and scrap alike (REQ-UI-SELECTION-CATEGORIES).
|
|
m_request.actors.clear();
|
|
m_request.debris.clear();
|
|
}
|
|
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)
|
|
{
|
|
m_request.actors = event->entities;
|
|
if (!m_request.actors.empty() || !m_request.debris.empty())
|
|
{
|
|
m_request.buildings.clear();
|
|
}
|
|
rebuildContent();
|
|
}
|
|
|
|
void SelectionPanel::handleEvent(
|
|
std::shared_ptr<const DebrisSelectionChangedEvent> event)
|
|
{
|
|
// Debris is a field object: it supersedes any building selection but coexists with
|
|
// actors (REQ-UI-SELECTION-CATEGORIES).
|
|
m_request.debris = event->debris;
|
|
if (!m_request.actors.empty() || !m_request.debris.empty())
|
|
{
|
|
m_request.buildings.clear();
|
|
}
|
|
rebuildContent();
|
|
}
|
|
|
|
void SelectionPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
|
|
{
|
|
refreshContent();
|
|
}
|
|
|
|
void SelectionPanel::handleEvent(
|
|
std::shared_ptr<const PlayerCommandsAppliedEvent> /*event*/)
|
|
{
|
|
// Player commands (choosing a shipyard schematic, say) are applied by a queued
|
|
// drain, not synchronously. When the game is paused no tick advances, so
|
|
// TickAdvancedEvent never fires; refreshing here too is what makes the change show
|
|
// up without waiting for a tick or a re-selection.
|
|
refreshContent();
|
|
}
|
|
|
|
void SelectionPanel::handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event)
|
|
{
|
|
m_debugDrawEnabled = event->active;
|
|
}
|
|
|
|
void SelectionPanel::refreshContent()
|
|
{
|
|
if (!m_content)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// A card never changes its own shape, so when the selection now calls for a
|
|
// different one it is replaced rather than refreshed. Only a single selected
|
|
// building can reach that state without the selection itself changing -- its
|
|
// construction site finishes, or it is deconstructed under the panel. Everything
|
|
// else is re-published as a selection change, so re-deriving the key here would walk
|
|
// a large multi-selection every tick to learn nothing.
|
|
if (m_request.buildings.size() == 1
|
|
&& chooseContent(m_request, *m_context.sim) != m_contentKey)
|
|
{
|
|
rebuildContent();
|
|
return;
|
|
}
|
|
|
|
m_content->refresh();
|
|
invalidateLayout();
|
|
}
|
|
|
|
void SelectionPanel::rebuildContent()
|
|
{
|
|
if (m_content)
|
|
{
|
|
// Retired rather than deleted: a rebuild can be reached from inside one of the
|
|
// card's own click handlers -- the recipe control opens a modal dialog and the
|
|
// choice comes back as a command -- and control has to be able to return into
|
|
// the widget that is going away.
|
|
m_content->hide();
|
|
m_content->deleteLater();
|
|
m_content = nullptr;
|
|
}
|
|
|
|
m_contentKey = chooseContent(m_request, *m_context.sim);
|
|
m_content = createContent(m_contentKey, m_request, m_context, m_body);
|
|
if (m_content)
|
|
{
|
|
m_bodyLayout->addWidget(m_content);
|
|
// The show is what makes the card count. A widget created under an
|
|
// already-visible parent starts hidden, and a layout treats a hidden item as
|
|
// empty -- it adds nothing to the size hint until something shows it, which
|
|
// otherwise does not happen until the event loop next runs, long after refit()
|
|
// has measured the panel. The panel then fits itself to an empty body and
|
|
// collapses to its scroll bar.
|
|
m_content->show();
|
|
m_content->refresh();
|
|
}
|
|
invalidateLayout();
|
|
}
|
|
|
|
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.
|
|
setVisible(m_content != nullptr);
|
|
if (m_content == nullptr || viewRect.isNull())
|
|
{
|
|
return;
|
|
}
|
|
|
|
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);
|
|
if (maxWidthPx <= 0 || band.height() <= 2 * borderPx)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// What the card asks for at a given width. The width has to be applied before
|
|
// asking, because a card's height depends on the room it is given -- and so, once
|
|
// laid out, does the width it reports. Measuring at whatever width the panel
|
|
// happens to have carries the previous card's shape into this one.
|
|
//
|
|
// Each measurement re-runs the body layout. Cards are built and discarded whole, so
|
|
// its cached hint describes the card before this one until it is invalidated, and
|
|
// re-running it is also what accounts for the parts a card hides and shows as it
|
|
// refreshes. The polish belongs to the same step: a freshly created chip reports an
|
|
// unstyled hint until the stylesheet has reached it, and the chips carry border and
|
|
// padding that change their size.
|
|
auto measureAt = [this](int widthPx) -> QSize
|
|
{
|
|
m_body->resize(widthPx, m_body->height());
|
|
m_body->ensurePolished();
|
|
m_body->layout()->invalidate();
|
|
m_body->layout()->activate();
|
|
return m_body->sizeHint();
|
|
};
|
|
|
|
// 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
|
|
// settles on the answer. Without it a card can end up a few pixels short of what it
|
|
// turns out to need, and the difference shows as a scroll bar over a card that
|
|
// looks like it fits.
|
|
for (int pass = 0; pass < 2; ++pass)
|
|
{
|
|
// 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 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
|
|
// left over.
|
|
const bool scrolls = (contentHeightPx > maxHeightPx);
|
|
int viewportWidthPx = contentWidthPx;
|
|
if (scrolls)
|
|
{
|
|
const int scrollBarWidthPx =
|
|
m_scrollArea->verticalScrollBar()->sizeHint().width();
|
|
contentWidthPx = qMin(contentWidthPx + scrollBarWidthPx, maxWidthPx);
|
|
contentHeightPx = maxHeightPx;
|
|
viewportWidthPx = contentWidthPx - scrollBarWidthPx;
|
|
measureAt(viewportWidthPx);
|
|
}
|
|
m_scrollArea->setVerticalScrollBarPolicy(
|
|
scrolls ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff);
|
|
|
|
// Left at the size the scroll area is about to give it, so the card is not
|
|
// briefly wider than its viewport.
|
|
m_body->resize(viewportWidthPx, m_body->sizeHint().height());
|
|
|
|
const int panelWidthPx = contentWidthPx + 2 * borderPx;
|
|
const int panelHeightPx = contentHeightPx + 2 * borderPx;
|
|
|
|
setGeometry(placeBesideAnchor(band, anchorRect, *m_side,
|
|
QSize(panelWidthPx, panelHeightPx),
|
|
occupiedRects, kMarginPx));
|
|
}
|
|
}
|