The layer was shown and painted before the modal it hosts was placed, so
opening one -- the layout dialog from the shipyard panel, say -- painted a
frame of bare dim over the world before the dialog appeared, which reads
as a black flash. The modal is now placed and shown while the layer is
still hidden, and showing the layer paints both at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
Every display naming an item now shows that item's production tooltip, not
only the selection panel's chips: the header block stock, the total cost of
a building multi-selection, the remaining scrap of a debris selection, the
icons of every recipe summary, and a blueprint card's cost.
ItemTooltip moves out of the selection panel and takes an
ItemTooltipContext of its own -- an item is named all over the UI, and the
panel's context carries visuals and a debug flag no tooltip reads. A
recipe line wraps each icon with its amount so the pair can be pointed at
as one statement, and attaches tooltips only where asked: a module button
and the item tooltip itself draw the same line and stay silent.
Hover only wherever the display sits on something the player clicks, whose
click is not free to explain. Note that a recipe line on an option button
can no longer be transparent to the mouse -- Qt never looks inside a
transparent widget for the cursor -- so it relies on press propagation to
keep picking the option.
The header block stock loses world.building_blocks_tooltip, showing what
every other block icon shows instead. The Expand button keeps no tooltip:
its cost is painted into its face with nowhere to hang one, and the button
is due to be removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
An item icon names an item, so the player can ask what it is wherever it
is drawn: a chip, a value stated as an amount plus a bare inline icon, or
an icon in a recipe summary. All three now show the item production
tooltip, with the amount beside the icon as part of the target.
An icon says nothing only where its display already carries a tooltip of
its own, since a display can only show one -- the build buttons, and the
module selection buttons, which are described controls whether or not a
given module fills its description in. On a clickable host the tooltip is
hover-only: the click belongs to the control.
The header bar's block stock loses its configured description in favour
of the production tooltip every other block icon shows, so
world.building_blocks_tooltip goes with REQ-UI-BLOCKS-TOOLTIP. It was the
only place stating what blocks are spent on, which no producer list says.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
One tooltip mechanism for the whole UI: a Tooltip popup plus a
TooltipTrigger event filter, replacing every setToolTip call. Qt's own
tooltip cannot do what REQ-UI-TOOLTIP-TRIGGER and REQ-UI-TOOLTIP-DISMISS
ask for -- it times out, hides on the first mouse move, cannot be hovered
and cannot be brought up by a click.
A tooltip now appears the moment an element with no click action of its
own is clicked, and it is placed with its top-left corner on the pointer,
so the pointer can move onto the tooltip and hold it open. The pointer is
polled while a tooltip is up rather than tracked through enter and leave
events, whose order depends on which widget the pointer crosses first.
The item chip's children become transparent to the mouse. They were
taking the chip's enter and leave events, so its tooltip only ever
appeared when the pointer rested on the chip's padding.
The modal header's close button loses its "Close" tooltip: a close button
in a header says that by being one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
An icon that displays a value and does nothing when clicked is free to use
the click for "tell me what this is", which beats waiting out the hover
delay and is the obvious thing to try. Buttons whose click already acts
keep hover only, so one gesture never both acts and explains.
Tooltips also stop timing out and become hoverable themselves, so a
tooltip listing several recipe lines can be read at any pace.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
REQ-UI-DIALOG-DISMISS asks that a dialog be put away by clicking away from
it, the gesture that already puts the selection panel away. The layer is
the widget those clicks land on, so this is where it belongs -- and the
three dialogs that take the gesture already say so through isDismissible(),
which Q has been using since the base class arrived.
A left press and a left release must both land beyond the open modal's
rectangle. Where the click landed is what decides, never that the layer
received it: a click on an inert part of a dialog -- a label, the gap
between two controls -- propagates up from the widget that ignored it and
arrives here with a position inside the modal, which is not a dismissal. A
drag begun inside the dialog never reaches the layer at all, the widget it
began on keeping the release, so no gesture ends by discarding the dialog it
was made in.
Every click on the layer is consumed either way, dismissing or not: the
window behind a modal takes no input, and closing the modal does not turn
that click into one for whatever lies under it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
The last dialogs wearing system chrome were the three QMessageBoxes -- the
escape menu, the game-over and win screens -- and the two QInputDialogs that
name a blueprint. They are the ones the player meets at the sharpest moments
of a run, and they looked like alerts from the operating system.
MessageDialog and NameInputDialog replace them on the layer. The message
dialog names its buttons by the index addButton hands back and reports which
was clicked, with Escape standing for a button the caller nominates rather
than for a dismissal of its own -- Continue in the escape menu, as it was
explicitly set to before, and Quit on the two state screens, which is where
the reject role sent it. Neither dialog takes Q or a click outside: every
button is a decision, and a half-typed name is work in progress
(REQ-UI-DIALOG-DISMISS).
Two things the layer needed for the nested case: it now tracks the scroll
area each modal is shown in, so a modal opened with no anchor centers on the
one it was opened from rather than on the window -- which is where the
Create Blueprint prompt belongs (REQ-UI-PANEL-MODAL) -- and findFor() walks
a widget's parents to the layer, so the blueprint panel buried in the layout
dialog can open a modal without every widget in between carrying a pointer.
The three error boxes stay system dialogs on purpose: config load, config
reload, and blueprint file load all report a failure that may leave nothing
to draw on (REQ-UI-MODAL-CHROME).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
Every dialog was an OS window with a dim widget behind it in the game
window. That is why a click beside one could not reach it: Qt drops mouse
events for a window a modal blocks, so the dim -- a child of the blocked
window -- never saw the press.
ModalLayer is that dim grown up: still a child of the main window covering
its rect, but it now hosts the open modal, places it, and runs its event
loop, so it is the widget the clicks beside a modal land on. It keeps a
stack, paints one dim however many modals are open, and a hold keeps it up
while one modal hands over to the next. ModalDimOverlay and ModalDimScope
are gone; ModalLayerHold replaces the scope at the sites that still open a
system message box.
ModalDialog is what a dialog inherits in place of the window it lost: the
panel background, the drawn header with its optional close button, and the
dismissal gestures. isDismissible() governs Q and, next, the click outside
-- one predicate because both gestures reach the same dialogs. Its default
refuses, so a dialog opts in. requestDismiss() is what a gesture asks for,
and ShipLayoutDialog overrides it with the one-step ladder its Q handler
used to spell out, which is now reached by both. DialogDismiss.h folded into
the base.
The four dialogs the player meets keep their contents unchanged and lose
their title bars: the recipe/schematic selection, blueprint selection, ship
layout, and schematic choice dialogs. Placement moved with them --
placeOnSelectionPanel became getSelectionPanelAnchor, and the layer centers
on that rectangle in window coordinates, with no global mapping and no
frame height to guess at.
Two things followed from dropping OS modality: the focus guard that hands
focus back to the game world now also asks the layer whether a modal is
open, and closeEvent refuses to close the window while one is, since its
nested loop runs over widgets the window owns.
The escape menu, the game-over and win screens, and the two name dialogs are
still system dialogs; they are dimmed by a layer hold until they are
converted next.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
The dialogs were OS windows with a dim widget behind them, which is why a
click beside one could not reach them: Qt drops mouse events for a window a
modal blocks, so the dim -- a child of the game window -- never sees the
press that REQ-UI-DIALOG-DISMISS now asks it to act on.
REQ-UI-MODAL-CHROME states the rule the fix follows: a modal is drawn by the
game on the dim, with no title bar, no window border, no window-manager
close, and no way for the player to move it, resize it, or drag it off the
window. Each draws its own header, as the blueprint selection dialog already
does; content too large for the window scrolls inside the modal instead of
hanging past the edge. The exception is failure reporting -- a config or
blueprint file that will not load is still a system message box, since it
must reach the player when there is nothing left to draw on.
The requirements that described the old chrome follow: the blueprint dialog
loses its window-manager close, the escape menu, game-over and win screens
and both name dialogs say they are drawn, and REQ-UI-MODAL-DIM says the dim
is the surface a modal sits on rather than a layer kept in step with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
A dialog was left by Q, Escape, or its own Cancel; a click beside it did
nothing, though that is exactly the gesture that already puts the selection
panel away. REQ-UI-DIALOG-DISMISS now covers both ways out, for the same
three dialogs and to the same effect, and the layout configuration dialog
backs out one step at a time under the click as it does under Q -- so a
mis-aimed click during module placement costs a re-click, not the session.
Three things the bare "clicks outside" left open are settled: the press and
the release must both land outside, so a drag begun in the dialog cannot end
by discarding it; the click is spent on the dismissal and does not reach what
it landed on; and the name-entry dialogs refuse it as they refuse Q, a
half-typed name being work in progress.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
Clearing a shipyard with "(None)" opened the layout configuration dialog on a
grid of no cells. The auto-open guard asked only whether the chosen id differs
from the current one, and the "(None)" option carries the empty id, which
differs from every schematic; ShipLayoutDialog then found no ship def and
derived a 0x0 grid. Predates this branch -- 698dd4d, 2026-07-13.
The question all three sites were answering by hand is now one:
findLayoutShipDef() returns the ship to configure a layout against, or nullptr
when no schematic is set, the id names no ship, or the ship defines no grid.
The auto-open path and the LayoutDialogRequestedEvent handler now ask it
before opening, and ShipyardContent asks it instead of spelling the same test
out for the preview and the Configure button.
The event handler was reachable only through a button ShipyardContent already
disables, so guarding it changes nothing today; it is guarded because the
dialog's precondition belongs to the dialog's entry, not to the widget that
happens to be the only caller.
REQ-MOD-UI-AUTO-DIALOG said "differs" and left clearing implicit, which is the
reading the code took. It now says clearing opens nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
Three dialogs take Q as a second way out beside Escape: the recipe/schematic
selection dialog and the blueprint selection dialog close outright, and the
layout configuration dialog steps out one level per press -- the module being
placed, then remove mode, then the session. Both of those mode exits now go
through the handler the Remove button uses, extracted from a lambda into
onRemoveButtonClicked(), so the key and the button cannot leave different
state behind.
The schematic choice dialog goes the other way and declines reject(). It had
no close button but Escape still closed it, and the caller then applied
choiceIndex 0 -- awarding whichever option happened to be first. Refusing
reject() covers Escape, Alt+F4, and the window manager together, since all
three funnel through it. It is also the only dialog whose dismissal would
strand state: the poll that opened it does not reopen it while the choices
stay pending, so a drop dismissed is a drop lost.
The key itself is spelled once in DialogDismiss.h rather than in three key
handlers. It stays out of the ControlAction table on purpose: that table
answers what an input does in the player's current situation, and a dialog
has none -- it holds focus and takes the key whatever the world is doing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
Q backs the player out in the game world, so it backs them out of a dialog
too: the recipe/schematic selection dialog, the blueprint selection dialog,
and the layout configuration dialog, where it is Cancel. In that last one it
steps out one level at a time as it does in the world -- a selected module,
then remove mode, then the session -- so one press never both leaves a mode
and discards the changes.
The schematic choice dialog goes the other way: it is now stated to be
undismissable, which it was already built to be (no close button) but was not
written down. Escape currently closes it and the handler then applies the
default choiceIndex of 0, awarding the first option the player never picked;
saying no way out exists but choosing is what closes that.
Q stays an ordinary character in the two dialogs that take a typed name. The
Escape bullet named only the blueprint dialog and was narrower than what
Escape has always done, so it now covers dismissible dialogs generally.
Requirements only; no code yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
Implements the requirements committed in 731b887.
Q becomes a three-way branch in the action table, which is the layer that owns
what an input does: ExitMode while a mode is active, the new ClearSelection
while something is selected, EnterDeconstruct otherwise. The three partition
the situations between them, so resolution stays first-match-wins over
available actions and the handler never re-derives the precedence -- which is
why ClearSelection gets its own event rather than joining ModeCancel on Q.
GameWorldView clears the selection at each of the three events that enter a
mode; those are the only ways in, whichever button or key the player used. No
clear is needed where ModeCancel falls through to deconstruct mode: Q resolves
to ClearSelection while anything is selected, so there is nothing left by then.
The Selection context's Q row reads "Clear selection", sits last as the row
that hands the context back does everywhere, and carries the exit badge
styling -- one key that backs out should look the same wherever it appears.
Requirements follow that last point in REQ-UI-CONTROLS-CARD and
REQ-UI-CONTROLS-CONTENT.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
Q backed out of a build mode or toggled deconstruct mode; it now clears the
selection as its middle case. That needed a rule for a selection held while a
build mode is active, a state the code allows today: clearAll() is called only
by a click or drag that hit nothing, so a selection survives into builder,
blueprint, and deconstruct mode.
Make the two mutually exclusive instead, matching what the controls panel
already shows: entering any build mode clears the selection. The blueprint
gestures read the selection before the mode entry clears it, so C and Ctrl+C
lose nothing.
Drops the accuracy carve-out that had C / Ctrl+C merely omitted from the build
contexts on the strength of a surviving selection -- with no selection there,
they are unavailable rather than omitted, and the list is the three cases its
intro claims.
Requirements only; no code yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
The panel had one distance for the view edges, the widgets it steps around,
and the selection alike, so it stood eight pixels from a building and touched
it outright along the top. The gap from the selection is now its own value,
half a tile, and it is horizontal only -- the top edges stay level.
It is sampled where the tile size is known, in the same moment as the anchor
rectangle, and travels with it: a rectangle frozen in one moment has no
meaningful distance to a tile size measured in another. chooseSide now takes
the gap in place of the margin, the band having already taken the margin off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
The panel kept one distance for everything: the view edges, the widgets it
steps around, and the selection itself. Beside a building that read as
touching it -- eight pixels to the side and nothing at all above, the top
edges flush. Split the two apart: an edge margin as before, and a selection
gap of half a tile, horizontal only, frozen with the anchor rectangle it is
measured from.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
A cursor resting on a panel or outside the window kept whatever it last
pointed at: the ghost, the tunnel preview, the deconstruct tint all stayed
put, because "not hovering" was not a state the build mode could hold. Make
both ghost tiles optional, clear the hover with them, and re-derive it when
the cursor comes back or a mode is entered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
Only the selection box was refreshed while the camera panned, so the ghost,
its validity, the resolved tunnel end and the deconstruct hover all kept the
tile of the last mouse move. Give the whole hover update one entry point and
run it from the pan step as well, for a cursor that is over the world.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
The controls panel header read the type builder mode was entered with, so
tunnel mode always said Tunnel Entry even where the ghost had resolved to an
exit. Feed the header the same effective type the ghost and placement already
use.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
Implements REQ-MAT-OUTPUT-GROUP. A recipe had two shapes -- outputs produced
together, or outputs of which exactly one happened -- and every rule over them
was written twice, selected by `building == ReprocessingPlant`: sizing a
buffer, deciding whether a cycle fits, resolving what a cycle makes, costing an
item. RecipeDef now holds output groups, each a weight and a list of items, and
a cycle yields exactly one group. One group is the ordinary recipe, so the old
two cases are the same shape with one and with several, and all four rules
collapse to one expression apiece with no building-type test left.
rollReprocessingOutput becomes rollOutputGroup, where a single group returns
without drawing or testing eligibility. That early-out is load-bearing twice
over. Drawing there would consume entropy for every ordinary recipe and shift
every later random outcome; and eligibility must not apply either, since
implicit unlocking is demand-derived, so an ordinary recipe's output can be
producible while nothing yet calls for it -- testing it would stop the building
producing rather than gate a drop. Past the early-out a group is eligible only
when all of its items are unlocked, being produced whole.
Threat follows the recipe's shape rather than the building, and the per-unit
value now divides by the group's amount as well as its odds. That moves no
number today: every item resolved through this path has amount 1, which is why
the threat expectations are untouched.
Config keeps `outputs = [...]` as the single-group form, so only the two
reprocessing recipes change shape. The recipe summary gains "/" between groups
and keeps "+" within one, which also fixes the plant reading as though a cycle
produced all of its items at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
Everything it used to specify moved out as the output-group merge generalised
it: the pick is REQ-MAT-OUTPUT-GROUP, the all-outcomes gate REQ-MAT-CYCLE, the
buffers REQ-MAT-OUTPUT-BUFFER, the eligible set REQ-LOCK-OUTPUT-POOL, the
recipe control REQ-BLD-AUTO-RECIPE. Restating them here only invited the two
to drift apart.
What is left is what config cannot say: why the building exists -- the
value-preserving counterpart to smelting scrap down, and the only path to
voidsteel -- plus the one rule that really is specific to it, that reprocessing
recipes take no part in the implicit unlock traversal, which until now was only
implied by REQ-LOCK-IMPLICIT naming the other three building types.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
It is no longer about reprocessing: any recipe with several output groups is
subject to it, and the plant is only the building that happens to have one
(REQ-MAT-OUTPUT-GROUP). REQ-LOCK-REPROCESSING-POOL becomes REQ-LOCK-OUTPUT-POOL.
Four citations, all in docs -- no code cites it.
Also corrects a line REQ-LOCK-IMPLICIT still carried from before smelters had a
recipe control: their recipes are not "never shown in any UI dropdown" any more,
they are simply never gated, so the dialog offers all of them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
Deterministic recipes could have several outputs produced together; a
probabilistic one could have several outputs of which exactly one happened.
Two shapes meant two rules everywhere -- sizing a buffer, deciding whether a
cycle fits, resolving what a cycle makes -- each written as a branch on
whether the building was a reprocessing plant.
New REQ-MAT-OUTPUT-GROUP merges them. A recipe has one or more output groups,
each with a weight and a list of items; a cycle produces exactly one group,
and the items within it together. One group is the ordinary recipe and is
always chosen, so the old deterministic and probabilistic cases are the same
shape with one group and with several -- and every rule downstream is written
over groups, needing no branch at all. Config keeps outputs = [...] as the
single-group form, so only the two reprocessing recipes change shape.
It also lets an outcome yield several items, which was unrepresentable, and
fixes a display bug on the way: the recipe summary drew all outputs as one
combined yield, so a plant read as if a cycle made all four items. Groups are
now separated by "/" and the items within one by "+".
REQ-LOCK-REPROCESSING-POOL now restricts the choice between groups rather
than the pool of output items, and says why that distinction is load-bearing:
implicit unlocking is demand-derived, so an ordinary recipe's output can be
producible while nothing yet calls for it. Testing eligibility there would not
gate a drop, it would stop the building producing at all -- so a recipe with
one group, having no choice to restrict, is never tested. A group is eligible
only if all of its items are unlocked, since they are produced together.
Requirements only; the implementation follows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x