scroll the controls panel's rows instead of clipping them

REQ-UI-CONTROLS-PANEL said the content scrolls once it outgrows the space
available; only the height cap was implemented, so the surplus rows were simply
cut off. Silent row loss is the one failure this panel must not have, and it
was reachable: the Selection context runs to fifteen rows, and a panel that has
also had to rise above the build button bar can be left with less room than
that.

The rows move into a scroll area. The heading stays outside it, so it remains
visible and remains the collapse control whatever the rows are doing.

The size can no longer come from the panel's own layout -- a scroll area's hint
describes a viewport, not its contents -- so the heading and the rows are
measured directly and the chrome added. Verified against a forced rebuild and
an artificially cramped view:

  roomy:    heading 56x13 + rows 124x188 -> 142x223, no scrollbar
  collapsed:                             ->  74x31, same bottom edge
  cramped:  wanted 223, band 124         -> 159x124, +17 for the scrollbar

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
This commit is contained in:
2026-08-07 20:39:06 +02:00
parent a5c51f08f8
commit 97c269576e
2 changed files with 59 additions and 16 deletions

View File

@@ -5,6 +5,8 @@
#include <QHBoxLayout>
#include <QLabel>
#include <QMouseEvent>
#include <QScrollArea>
#include <QScrollBar>
#include <QTimer>
#include <QVBoxLayout>
@@ -15,10 +17,15 @@
namespace
{
const int kMarginPx = 8; // between the band's edge and the panel
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
@@ -117,7 +124,7 @@ ControlsPanel::ControlsPanel(const GameWorldView* view, QWidget* parent)
QVBoxLayout* outerLayout = new QVBoxLayout(this);
outerLayout->setContentsMargins(kCardMarginPx, kCardMarginPx,
kCardMarginPx, kCardMarginPx);
outerLayout->setSpacing(4);
outerLayout->setSpacing(kHeadingGapPx);
m_heading = new QLabel(this);
m_heading->setObjectName(QStringLiteral("controlHeading"));
@@ -125,11 +132,24 @@ ControlsPanel::ControlsPanel(const GameWorldView* view, QWidget* parent)
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);
outerLayout->addWidget(m_rows);
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);
@@ -153,7 +173,7 @@ void ControlsPanel::mousePressEvent(QMouseEvent* event)
if (m_heading->geometry().contains(event->pos()))
{
m_collapsed = !m_collapsed;
m_rows->setVisible(!m_collapsed);
m_scrollArea->setVisible(!m_collapsed);
refit();
}
event->accept();
@@ -220,7 +240,7 @@ void ControlsPanel::rebuild(const ControlContext& context)
addAndShow(m_rowsLayout, makeRow(action, context, m_rows));
}
m_rows->setVisible(!m_collapsed);
m_scrollArea->setVisible(!m_collapsed);
refit();
}
@@ -270,22 +290,33 @@ void ControlsPanel::refit()
m_rowsLayout->invalidate();
m_rowsLayout->activate();
// Deliberately not activating the panel's own layout here. That lays the heading
// and the rows out inside the geometry the panel still has from the previous
// context, which is the wrong frame of reference for choosing the new one.
// totalSizeHint answers "how big does this need to be" without reference to how big
// it currently is; setGeometry below then re-runs the outer layout on its own.
layout()->invalidate();
const QSize needed = layout()->totalSizeHint();
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).
const int widthPx = qMin(needed.width(), band.width());
int heightPx = qMin(needed.height(), band.height());
int topPx = band.bottom() - heightPx + 1;
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
@@ -299,5 +330,13 @@ void ControlsPanel::refit()
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);
}