Skip to content

UI: a panel widget, and a border that stays inside the box it was given - #1740

Merged
obiot merged 1 commit into
masterfrom
feat/uipanel
Oct 16, 2026
Merged

obiot merged 1 commit into
masterfrom
feat/uipanel

Conversation

@obiot

@obiot obiot commented Oct 16, 2026

Copy link
Copy Markdown
Member

Closes part 4 of #1722.

UIPanel

A rectangular surface with a background, an optional border and children: what every menu, dialog and HUD frame starts by writing by hand.

const card = new UIPanel(cx - 230, cy - 128, {
    width: 460, height: 256, radius: 14,
    fillColor: "#0a1526",
    borderColor: "rgba(130, 200, 240, 0.55)", borderWidth: 2,
});
card.addChild(new Text(230, 26, { text: "PAUSED", textAlign: "center" }));

It is a UIBaseElement, so children are positioned relative to it and are asked about a click before it is, and blocking (default true) decides whether a click that misses every child is swallowed rather than reaching the game beneath.

One example in this repo had three hand-rolled panels, each repeating the same half dozen renderer calls and the same correction for the way setColor folds a colour's own alpha into the global one. That correction is why this is a widget rather than a snippet: leaving it out does not fail, it just renders at the wrong opacity.

The bob

bobAmplitude / bobPeriod give a panel the slow float an idle dialogue or pause panel usually has, so a screen waiting for input does not look frozen.

The interesting part is that the panel stays clickable where it is drawn. The offset goes into getAbsolutePosition(), the one hook both bounds paths funnel through, so the hit area of the panel and of every widget inside it follows the float at any depth. pos is untouched, so a caller still reads and writes the resting place.

Two routes I tried first and rejected, both measured:

  • currentTransform reaches a renderable's own bounds but not its children's. A bobbing panel's swallow rectangle would drift off the widgets sitting in it, which is worse than either consistent answer.
  • Writing pos.y reaches everything correctly but silently eats a caller's own write on the next frame. Fixing that needs re-entrancy detection inside the ObservableVector3d callback.

Particle overrides the same methods for the same reason, its placement not being in its pos chain either.

The offset is rounded, and that is not cosmetic: the renderers snap a translation with |0, truncating toward zero, so an amplitude of 3 travelled 4px instead of 6 and spent half the cycle parked at the extremes. Handing the renderer a whole number makes its own snap a no-op, and makes the drawn position and the hit area agree exactly.

Two shipped ProgressBar fixes

ProgressBar and UIPanel now share the shape and colour plumbing, which is how the border came to be drawn two different ways in two widgets that look the same.

  • The border was centred on the bar's own edge, so half of it fell outside the width and height the bar was given and painted over whatever sat next to it, while getBounds() knew nothing about those pixels. A square border is now four fills tiling the frame exactly; a rounded one is stroked on a path inset by half the border width with its radius pulled in to match. This also fixes a square border at the default width of 1 losing all four corners, since the renderers only generate corner joins above a line width of one.
  • Destroying one twice threw Instance is already in pool. It handed its cached colour back without clearing the handle, and a pool refuses a double release.

Testing

uipanel.spec.js is new (the widget had none); progressbar.spec.js and ui-interaction.spec.js gain geometry and pointer coverage, including a button nested in a bobbing panel and a grandchild two levels down.

Everything here was mutation-tested. Three of my own tests turned out to be decoration and were rewritten — one asserted on a sine's output where the claim was about the clock behind it, one could not see a pos-writing bob because the spec never drew, and one "anchors top left" test passed with the line deleted because Container already pins it. That last one found dead code, which is gone.

Full suite green, declarations check clean.

`UIPanel` is a rectangular surface with a background, an optional border and
children: the thing every menu, dialog and HUD frame starts by writing by
hand. One example in this repository had three of them, each repeating the
same half dozen renderer calls and the same correction for the way
`setColor` folds a colour's own alpha into the global one. That correction
is why this is a widget rather than a snippet: leaving it out does not fail,
it renders the panel at the wrong opacity.

It is a `UIBaseElement`, so children are positioned relative to it and are
asked about a click before it is, and `blocking` decides whether a click
that misses every child is swallowed rather than reaching the game beneath.

`bobAmplitude` / `bobPeriod` give it the slow float an idle dialogue or
pause panel usually has. The offset goes into `getAbsolutePosition()`, which
is the one hook both bounds paths funnel through, so the panel is clickable
WHERE IT IS DRAWN and so is every widget inside it, at any depth. `pos` is
left alone, so a caller still reads and writes the resting place rather than
a value that moves under them. `currentTransform` was the obvious route and
is a dead end: it reaches a renderable's own bounds but not its children's,
so a bobbing panel's swallow rectangle would drift off the widgets sitting
in it.

`ProgressBar` and `UIPanel` now share the shape and colour plumbing, which
is how the border came to be drawn two different ways in two widgets that
look the same. Two shipped `ProgressBar` defects go with it:

- Its border was centred on the bar's own edge, so half of it fell outside
  the width and height the bar was given and painted over its neighbour,
  while `getBounds()` knew nothing about those pixels. A square border is
  four fills tiling the frame exactly; a rounded one is stroked on a path
  inset by half the border width with its radius pulled in to match. This
  also fixes a square border at the default width of 1 losing all four
  corners, since the renderers only generate corner joins above a line
  width of one.
- Destroying one twice threw `Instance is already in pool.` It handed its
  cached colour back without clearing the handle, and a pool refuses a
  double release.

The bob offset is rounded, which is not cosmetic: the renderers snap a
translation with `|0`, truncating toward zero, so an amplitude of 3 moved
4px instead of 6 and spent half the cycle parked at the extremes. Handing
the renderer a whole number makes its own snap a no-op, and makes the drawn
position and the hit area agree exactly.

Closes part 4 of #1722.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Copilot AI balanced review requested due to automatic review settings October 16, 2026 02:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@obiot
obiot merged commit e1561a7 into master Oct 16, 2026
6 checks passed
@obiot
obiot deleted the feat/uipanel branch October 16, 2026 03:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants