#include "ControlsPanel.h" #include #include #include #include #include #include #include #include #include #include "ControlActionText.h" #include "GameWorldView.h" #include "selection/SelectionNames.h" namespace { const int kMarginPx = 8; // between the view's edge and the panel const int kCardMarginPx = 8; // inside the panel, around its content const int kHeadingGapPx = 4; // between the heading and the rows const int kRefreshMs = 50; // see the class comment on why this polls // The panel's border, from the stylesheet below. Spelled out because the stylesheet box // is what sets it and asking the style for it before the first show is unreliable. const int kBorderPx = 1; // Separates the heading's name from its detail, e.g. "BUILD MODE * Assembler". const QChar kHeadingSeparator(0x00B7); // U+00B7 MIDDLE DOT // The upper-case heading and caption are tracked out a little so they read as labels // rather than as words. Set on the font because Qt's stylesheets have no letter-spacing // property -- writing one there is silently ignored apart from a warning per widget. QFont makeSpacedFont(QFont font, bool bold, int pointSizeDelta) { font.setBold(bold); if (pointSizeDelta != 0 && font.pointSize() > 0) { font.setPointSize(qMax(1, font.pointSize() + pointSizeDelta)); } font.setLetterSpacing(QFont::AbsoluteSpacing, 1.0); return font; } // One row: the chips for the bindings, then what the action is called. Built as a plain // widget rather than a class of its own -- it holds no state and answers no questions. QWidget* makeRow(ControlAction action, const ControlContext& context, QWidget* parent) { QWidget* row = new QWidget(parent); QHBoxLayout* layout = new QHBoxLayout(row); layout->setContentsMargins(0, 2, 0, 2); layout->setSpacing(4); // Every badge is rendered from the binding the resolver matches, so a chip cannot // claim a key that does nothing (REQ-UI-CONTROLS-ACCURACY). The chips of the row // that leaves the mode are the ones marked, not its label (REQ-UI-CONTROLS-CARD). const bool exitsMode = (action == ControlAction::ExitMode); const std::vector bindings = getControlActionBindings(action, context); for (const ControlBinding& binding : bindings) { QLabel* badge = new QLabel(getControlBindingBadge(binding), row); badge->setObjectName(exitsMode ? QStringLiteral("controlBadgeExit") : QStringLiteral("controlBadge")); layout->addWidget(badge); } QLabel* label = new QLabel(getControlActionLabel(action, context), row); label->setObjectName(QStringLiteral("controlLabel")); layout->addSpacing(4); layout->addWidget(label); layout->addStretch(1); return row; } // Adds a freshly built widget to the rows and shows it. // // The show is what makes it count. A widget created under an already-visible parent // starts hidden, and a layout treats a hidden item as empty -- it contributes nothing // to the size hint until something shows it, which otherwise does not happen until the // event loop next runs, long after the panel has measured itself. void addAndShow(QVBoxLayout* layout, QWidget* widget) { layout->addWidget(widget); widget->show(); } } // namespace ControlsPanel::ControlsPanel(const GameWorldView* view, QWidget* parent) : QWidget(parent) , m_view(view) { // Floats over the rendered world, so it brings its own background to stay legible // over any world content (REQ-UI-CONTROLS-PANEL). Palette colors match the build // button bar's and the selection panel's chrome; like them 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 rather than cascading onto // its children. setAttribute(Qt::WA_StyledBackground, true); // Letter spacing is deliberately absent here: Qt's stylesheet syntax has no such // property and warns on every widget it is applied to. The heading and the caption // set it on their QFont instead. setStyleSheet(QStringLiteral( "ControlsPanel { background-color: palette(window);" " border: 1px solid palette(mid); border-radius: 4px; }" "QLabel#controlHeading { color: palette(text); }" "QLabel#controlBadge { border: 1px solid palette(mid); border-radius: 3px;" " padding: 1px 5px; font-family: monospace; color: palette(text); }" "QLabel#controlLabel { color: palette(text); }" // The row that leaves the mode is marked on its chips rather than its label // (REQ-UI-CONTROLS-CARD): the label keeps the ordinary text color, so the row // stays legible whatever the palette, and only the chips carry the warning. // // A literal red because there is no palette role for "destructive" -- the // nearest, bright-text, is white by design, being meant for text over dark // highlights, and was unreadable on this panel's chrome. This value reads on a // light and a dark background alike. It is widget chrome, so like the rest of // this stylesheet it is deliberately not a visuals.toml color. "QLabel#controlBadgeExit { border: 1px solid #c0392b; border-radius: 3px;" " padding: 1px 5px; font-family: monospace; color: #c0392b; }" "QLabel#controlCaption { color: palette(mid); }")); QVBoxLayout* outerLayout = new QVBoxLayout(this); outerLayout->setContentsMargins(kCardMarginPx, kCardMarginPx, kCardMarginPx, kCardMarginPx); outerLayout->setSpacing(kHeadingGapPx); m_heading = new QLabel(this); m_heading->setObjectName(QStringLiteral("controlHeading")); m_heading->setCursor(Qt::PointingHandCursor); m_heading->setFont(makeSpacedFont(font(), /*bold*/ true, /*pointSizeDelta*/ 0)); outerLayout->addWidget(m_heading); // Rows taller than the space available scroll rather than being cut off // (REQ-UI-CONTROLS-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_rows = new QWidget(this); m_rowsLayout = new QVBoxLayout(m_rows); m_rowsLayout->setContentsMargins(0, 0, 0, 0); m_rowsLayout->setSpacing(0); m_rows->setAutoFillBackground(false); m_scrollArea = new QScrollArea(this); m_scrollArea->setFrameShape(QFrame::NoFrame); m_scrollArea->setWidgetResizable(true); m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); m_scrollArea->viewport()->setAutoFillBackground(false); m_scrollArea->setWidget(m_rows); outerLayout->addWidget(m_scrollArea); // Polling rather than subscribing; see the class comment. m_refreshTimer = new QTimer(this); connect(m_refreshTimer, &QTimer::timeout, this, &ControlsPanel::refresh); m_refreshTimer->start(kRefreshMs); refresh(); } void ControlsPanel::anchorTo(const QRect& worldRect, const QRect& buildBarRect) { m_worldRect = worldRect; m_buildBarRect = buildBarRect; refit(); } void ControlsPanel::mousePressEvent(QMouseEvent* event) { // Only the heading toggles; a click anywhere else is swallowed so it never reaches // the world beneath (REQ-UI-CONTROLS-PANEL). if (m_heading->geometry().contains(event->pos())) { m_collapsed = !m_collapsed; m_scrollArea->setVisible(!m_collapsed); refit(); } event->accept(); } void ControlsPanel::refresh() { if (!m_view) { return; } const ControlContext context = m_view->getControlContext(); const QString heading = getHeadingText(context); const std::vector contextActions = getContextActions(context); const std::vector alwaysActions = getAlwaysAvailableActions(context); if (heading == m_shownHeading && contextActions == m_shownContextActions && alwaysActions == m_shownAlwaysActions) { return; } m_shownHeading = heading; m_shownContextActions = contextActions; m_shownAlwaysActions = alwaysActions; rebuild(context); } void ControlsPanel::rebuild(const ControlContext& context) { m_heading->setText(m_shownHeading); // Rows are rebuilt wholesale rather than reconciled: a context change replaces // nearly all of them, and the panel redraws only when something actually changed. while (QLayoutItem* item = m_rowsLayout->takeAt(0)) { delete item->widget(); delete item; } for (ControlAction action : m_shownContextActions) { addAndShow(m_rowsLayout, makeRow(action, context, m_rows)); } // The always-available block sits under a divider in every context, the General one // included, so the card is read the same way wherever the player is // (REQ-UI-CONTROLS-CARD). if (!m_shownAlwaysActions.empty()) { QFrame* divider = new QFrame(m_rows); divider->setFrameShape(QFrame::HLine); divider->setFrameShadow(QFrame::Plain); m_rowsLayout->addSpacing(6); addAndShow(m_rowsLayout, divider); QLabel* caption = new QLabel(tr("ALWAYS AVAILABLE"), m_rows); caption->setObjectName(QStringLiteral("controlCaption")); caption->setFont(makeSpacedFont(font(), /*bold*/ false, /*pointSizeDelta*/ -1)); addAndShow(m_rowsLayout, caption); } for (ControlAction action : m_shownAlwaysActions) { addAndShow(m_rowsLayout, makeRow(action, context, m_rows)); } m_scrollArea->setVisible(!m_collapsed); refit(); } QString ControlsPanel::getHeadingText(const ControlContext& context) const { const ControlContextKind kind = getControlContextKind(context); const QString name = getControlContextName(kind); QString detail; switch (kind) { case ControlContextKind::Build: detail = getBuildingTypeName(context.builderType); break; case ControlContextKind::Blueprint: { // The temporary blueprint is never named (REQ-UI-BLUEPRINT-TEMP), so it is // labelled by what it is rather than left blank. const QString blueprintName = m_view->getActiveBlueprintName(); detail = blueprintName.isEmpty() ? tr("Temporary") : blueprintName; break; } case ControlContextKind::Selection: detail = context.selection == ControlSelection::Buildings ? tr("%n building(s)", "", context.selectionCount) : tr("%n object(s)", "", context.selectionCount); break; case ControlContextKind::General: case ControlContextKind::Deconstruct: break; } if (detail.isEmpty()) { return name; } return name + QStringLiteral(" ") + kHeadingSeparator + QStringLiteral(" ") + detail; } void ControlsPanel::refit() { if (m_worldRect.isNull()) { return; } // Rows are torn down and rebuilt wholesale, and a widget added to a layout is only // shown once that layout runs -- without this the new rows count for nothing and // the card is measured for the context before it. The polish belongs to the same // step: a freshly created label reports an unstyled size hint until the stylesheet // has reached it, and the badge chips carry border and padding that change it. m_rows->ensurePolished(); m_rowsLayout->invalidate(); m_rowsLayout->activate(); const QRect band = m_worldRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx); if (band.width() <= 0 || band.height() <= 0) { return; } // Measured from the heading and the rows directly rather than from the panel's own // layout: the rows now sit in a scroll area, whose size hint describes a viewport // and says nothing about how tall its contents are. Deliberately not activating the // panel's own layout either -- that lays its children out inside the geometry left // over from the previous context, which is the wrong frame of reference for // choosing the new one. setGeometry below re-runs it. const int chromePx = 2 * (kCardMarginPx + kBorderPx); const QSize headingHint = m_heading->sizeHint(); const QSize rowsHint = m_rows->sizeHint(); int contentWidthPx = headingHint.width(); int contentHeightPx = headingHint.height(); if (!m_collapsed) { contentWidthPx = qMax(contentWidthPx, rowsHint.width()); contentHeightPx += kHeadingGapPx + rowsHint.height(); } const int wantedHeightPx = contentHeightPx + chromePx; // The bottom-left corner of the view, growing upward as rows are added // (REQ-UI-CONTROLS-PANEL). int widthPx = qMin(contentWidthPx + chromePx, band.width()); int heightPx = qMin(wantedHeightPx, band.height()); int topPx = band.bottom() - heightPx + 1; // The build button bar is centered and sized to its buttons, so it usually leaves // this corner free and the panel can share the bottom edge with it. Only when the // two would actually overlap does the panel rise, clearing the bar's top by the // same margin it keeps from the view's edges (REQ-UI-BUILD-BAR). const QRect wanted(band.left(), topPx, widthPx, heightPx); if (!m_buildBarRect.isNull() && wanted.intersects(m_buildBarRect)) { const int availablePx = m_buildBarRect.top() - kMarginPx - band.top(); heightPx = qMin(heightPx, qMax(0, availablePx)); topPx = m_buildBarRect.top() - kMarginPx - heightPx; } // Whatever the rows lost to either cap, they scroll for. The scrollbar needs its // own width, or it would appear over the labels. if (heightPx < wantedHeightPx) { widthPx = qMin(widthPx + m_scrollArea->verticalScrollBar()->sizeHint().width(), band.width()); } setGeometry(band.left(), topPx, widthPx, heightPx); }