2 Commits

Author SHA1 Message Date
f06d79e60d document the player-input design in architecture.md
The doc's job is the invariants that are easy to break, and this change added
three of them that only existed as comments in the files enforcing them. The
one that matters most is the first: adding a shortcut straight to InputMapper's
switch is the natural next edit, it works, and it silently reintroduces exactly
the drift the action table was built to prevent.

Also records the two distinctions that cost the most to rediscover -- that an
action can be available in a context the panel does not advertise it in, and
that gesture state belongs to the shared mode rather than to an action, because
it decides what other bindings mean.

The ui target's contents were listed without the controls panel too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-07 20:40:03 +02:00
97c269576e 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
2026-08-07 20:39:06 +02:00
3 changed files with 78 additions and 17 deletions

View File

@@ -124,13 +124,31 @@ Within a single simulation tick, subsystems run in this fixed order. The order i
Three product targets plus tests: Three product targets plus tests:
- `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets. - `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets.
- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selection panel, build button bar. Depends on `lib` and on Qt's OpenGL widgets module. - `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selection panel, build button bar, controls panel. Depends on `lib` and on Qt's OpenGL widgets module.
- `ui/selection/` — the selection panel's contents. `SelectionPanel` itself only arbitrates between the two selection categories, picks a card from the catalog (`SelectionContentFactory`), and hosts one at a time; each kind of selection has its own `SelectionContent` subclass assembled from shared parts (REQ-UI-SELECTION-CARD, REQ-UI-SELECTION-CONTENT). - `ui/selection/` — the selection panel's contents. `SelectionPanel` itself only arbitrates between the two selection categories, picks a card from the catalog (`SelectionContentFactory`), and hosts one at a time; each kind of selection has its own `SelectionContent` subclass assembled from shared parts (REQ-UI-SELECTION-CARD, REQ-UI-SELECTION-CONTENT).
- `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`. - `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`.
- `tests/` — Catch2 tests. Links only against `lib`. - `tests/` — Catch2 tests. Links only against `lib`.
Directory discipline inside `lib/` keeps the internal sim/config seam clear; sim code must not reach into config parsing and vice versa. Directory discipline inside `lib/` keeps the internal sim/config seam clear; sim code must not reach into config parsing and vice versa.
## Player Input
Every player control is declared once, in `lib/core/ControlAction.h`, and read by three consumers that must never disagree about it:
* **`ControlsPanel`** asks which actions apply and draws a row per action (REQ-UI-CONTROLS-CONTENT).
* **`InputMapper`** resolves a key press to an action and fires the event that action stands for.
* **`GameWorldView`** resolves a mouse gesture to an action and runs the branch that carries it out.
The file declares; it never performs. It holds no simulation access, fires no events, and names nothing — display strings live in `ui/ControlActionText.h`, which renders each badge from the binding the resolver actually matches, so a chip cannot claim a key that does nothing. What an action *does* stays in the widget that always did it: the drag state machines, hit-testing, and command enqueuing were not moved.
Three invariants are easy to break here:
* **Do not add a shortcut straight to `InputMapper`'s switch or `mousePressEvent`'s branches.** Add the action and its binding to the table; the handler switches on the resolved action. A binding added directly is invisible to the panel, which is the drift the table exists to prevent. (Build hotkeys and `F3`/`F4` are deliberate exceptions, documented in the header and in REQ-UI-CONTROLS-ACCURACY.)
* **Availability and display are different questions.** An action can be live in a context the panel does not advertise it in — `Ctrl`+click with an empty selection is the standing example. `isControlActionAvailable` answers the first, the per-context row lists answer the second, and `ControlActionTest` asserts the pairing that matters: every row's bindings resolve back to that row's action.
* **Gesture state is shared, not owned by an action.** Whether a belt drag is in progress decides what the right mouse button means, so it lives on `BuildModeController` where the resolver can see it — as does the hovered-transfer flag. `m_boxSelecting` is likewise one gesture serving two actions (box select and deconstruct area).
`ControlContext` is a plain snapshot rather than references to the live controllers, which is what keeps the rules testable without a world and stops an action reaching into the simulation: if a rule needs a fact, the fact is named in the struct and the caller supplies it. When bindings become player-configurable, only the binding tables in `ControlAction.cpp` turn from hard-coded data into loaded data.
## Belt Subsystem ## Belt Subsystem
Belts and splitters are their own specialized subsystem. Belt items are **not** entities — they are transient data flowing through the belt representation. They do not have identities that persist across ticks. Belts and splitters are their own specialized subsystem. Belt items are **not** entities — they are transient data flowing through the belt representation. They do not have identities that persist across ticks.

View File

@@ -5,6 +5,8 @@
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QMouseEvent> #include <QMouseEvent>
#include <QScrollArea>
#include <QScrollBar>
#include <QTimer> #include <QTimer>
#include <QVBoxLayout> #include <QVBoxLayout>
@@ -15,10 +17,15 @@
namespace 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 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 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". // Separates the heading's name from its detail, e.g. "BUILD MODE * Assembler".
const QChar kHeadingSeparator(0x00B7); // U+00B7 MIDDLE DOT const QChar kHeadingSeparator(0x00B7); // U+00B7 MIDDLE DOT
@@ -117,7 +124,7 @@ ControlsPanel::ControlsPanel(const GameWorldView* view, QWidget* parent)
QVBoxLayout* outerLayout = new QVBoxLayout(this); QVBoxLayout* outerLayout = new QVBoxLayout(this);
outerLayout->setContentsMargins(kCardMarginPx, kCardMarginPx, outerLayout->setContentsMargins(kCardMarginPx, kCardMarginPx,
kCardMarginPx, kCardMarginPx); kCardMarginPx, kCardMarginPx);
outerLayout->setSpacing(4); outerLayout->setSpacing(kHeadingGapPx);
m_heading = new QLabel(this); m_heading = new QLabel(this);
m_heading->setObjectName(QStringLiteral("controlHeading")); 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)); m_heading->setFont(makeSpacedFont(font(), /*bold*/ true, /*pointSizeDelta*/ 0));
outerLayout->addWidget(m_heading); 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_rows = new QWidget(this);
m_rowsLayout = new QVBoxLayout(m_rows); m_rowsLayout = new QVBoxLayout(m_rows);
m_rowsLayout->setContentsMargins(0, 0, 0, 0); m_rowsLayout->setContentsMargins(0, 0, 0, 0);
m_rowsLayout->setSpacing(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. // Polling rather than subscribing; see the class comment.
m_refreshTimer = new QTimer(this); m_refreshTimer = new QTimer(this);
@@ -153,7 +173,7 @@ void ControlsPanel::mousePressEvent(QMouseEvent* event)
if (m_heading->geometry().contains(event->pos())) if (m_heading->geometry().contains(event->pos()))
{ {
m_collapsed = !m_collapsed; m_collapsed = !m_collapsed;
m_rows->setVisible(!m_collapsed); m_scrollArea->setVisible(!m_collapsed);
refit(); refit();
} }
event->accept(); event->accept();
@@ -220,7 +240,7 @@ void ControlsPanel::rebuild(const ControlContext& context)
addAndShow(m_rowsLayout, makeRow(action, context, m_rows)); addAndShow(m_rowsLayout, makeRow(action, context, m_rows));
} }
m_rows->setVisible(!m_collapsed); m_scrollArea->setVisible(!m_collapsed);
refit(); refit();
} }
@@ -270,22 +290,33 @@ void ControlsPanel::refit()
m_rowsLayout->invalidate(); m_rowsLayout->invalidate();
m_rowsLayout->activate(); 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); const QRect band = m_worldRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx);
if (band.width() <= 0 || band.height() <= 0) { return; } 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 // The bottom-left corner of the view, growing upward as rows are added
// (REQ-UI-CONTROLS-PANEL). // (REQ-UI-CONTROLS-PANEL).
const int widthPx = qMin(needed.width(), band.width()); int widthPx = qMin(contentWidthPx + chromePx, band.width());
int heightPx = qMin(needed.height(), band.height()); int heightPx = qMin(wantedHeightPx, band.height());
int topPx = band.bottom() - heightPx + 1; int topPx = band.bottom() - heightPx + 1;
// The build button bar is centered and sized to its buttons, so it usually leaves // 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 // 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; 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); setGeometry(band.left(), topPx, widthPx, heightPx);
} }

View File

@@ -10,6 +10,7 @@
class GameWorldView; class GameWorldView;
class QLabel; class QLabel;
class QScrollArea;
class QTimer; class QTimer;
class QVBoxLayout; class QVBoxLayout;
@@ -64,6 +65,9 @@ private:
const GameWorldView* m_view; const GameWorldView* m_view;
QLabel* m_heading; QLabel* m_heading;
// Scrolls the rows once they outgrow the space the panel has (REQ-UI-CONTROLS-PANEL).
// The heading is deliberately outside it, so it stays put and stays clickable.
QScrollArea* m_scrollArea;
QWidget* m_rows; QWidget* m_rows;
QVBoxLayout* m_rowsLayout; QVBoxLayout* m_rowsLayout;
QTimer* m_refreshTimer; QTimer* m_refreshTimer;