427 lines
18 KiB
C++
427 lines
18 KiB
C++
#include "SelectionPanel.h"
|
|
|
|
#include <QMouseEvent>
|
|
#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::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;
|
|
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();
|
|
// 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(
|
|
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);
|
|
|
|
// 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
|
|
// 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();
|
|
};
|
|
|
|
// 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
|
|
// 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 =
|
|
solve(QSize(contentWidthPx + 2 * borderPx, band.height())).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(solve(QSize(panelWidthPx, panelHeightPx)));
|
|
}
|
|
}
|