Files
dota_factory/src/ui/ControlsPanel.cpp
Malte Langkabel e66eb7a81f place the floating widgets in one ordered pass
The three widgets over the game world view each cached a rect handed to them by
MainWindow's resize, then re-placed themselves from it. But the build button bar
re-centers on a building unlock and the controls panel re-fits on a 50 ms timer,
neither of which goes through MainWindow, so the rects the others held went
stale -- and each widget re-implemented its own avoidance against them.

They now implement FloatingPanel and are placed in one ordered pass: the bar
takes what it wants, the controls panel steps around the bar, and the selection
panel keeps clear of both. A widget that changed size or visibility publishes
FloatingLayoutInvalidatedEvent instead of moving itself, because what it may
take depends on the widgets placed before it.

The rule they step around each other by is one function in lib, where it can be
tested without a display -- the only way any of this geometry gets automated
cover, screen capture of the world view being blank here.

The selection panel keeps its right edge and its vertical centering, but the
space it centers in is now what its own column has left free rather than the
full-width strip the bar used to reserve. It therefore sits lower than before
where the centered bar does not reach it, and it now clears the controls panel,
which it previously ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
2026-08-08 19:10:00 +02:00

341 lines
14 KiB
C++

#include "ControlsPanel.h"
#include <QFont>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QMouseEvent>
#include <QScrollArea>
#include <QScrollBar>
#include <QTimer>
#include <QVBoxLayout>
#include "ControlActionText.h"
#include "EventManager.h"
#include "FloatingLayoutInvalidatedEvent.h"
#include "FloatingPanelPlacement.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<ControlBinding> 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::invalidateLayout()
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<FloatingLayoutInvalidatedEvent>());
}
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);
invalidateLayout();
}
event->accept();
}
void ControlsPanel::refresh()
{
if (!m_view) { return; }
const ControlContext context = m_view->getControlContext();
const QString heading = getHeadingText(context);
const std::vector<ControlAction> contextActions = getContextActions(context);
const std::vector<ControlAction> 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);
invalidateLayout();
}
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::placeIn(const QRect& viewRect, const std::vector<QRect>& occupiedRects)
{
if (viewRect.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 = viewRect.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());
// 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 where the
// two would actually meet does the panel rise, clearing the bar's top by the same
// margin it keeps from the view's edges (REQ-UI-BUILD-BAR). The bar is the only
// widget placed before this one, so it is the only rect that can be in the way.
const int bottomPx = getAvailableBottomPx(band, occupiedRects, band.left(),
band.left() + widthPx - 1, kMarginPx);
int heightPx = qMin(wantedHeightPx, qMax(0, bottomPx - band.top() + 1));
int topPx = bottomPx - heightPx + 1;
// 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);
}