#include "SelectionPanel.h" #include #include #include #include #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()); } 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 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 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 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 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 /*event*/) { refreshContent(); } void SelectionPanel::handleEvent( std::shared_ptr /*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 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& 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))); } }