Technical diary

A UI where nothing knows where it is

Every control's bounds were a fraction of the whole window, so a nested panel's position was multiplied out by hand at every authoring site. Now a widget owns its children, rows are placed by their container at layout time rather than authored, and detail inside a control is measured in text rather than pixels. The part worth having is the check: an audit that reports any widget painting outside the area it was given.

Every widget in this game's UI has an area. The question the whole thread turns on is whose it is, and for a long time the answer was "the window's" — every control, however deeply nested it looked on screen, was authored as a fraction of the whole screen.

The primitive was not wrong. ui::Widget already carried normalized [0..1] bounds and a Layout(container) that resolved them against a pixel rect. What was missing was children. Nothing owned a subtree, so the only widget that ever passed a non-window container was the tab control, and every other parent/child relationship in the game was multiplied out by hand at authoring time:

kPanelX  = 1 - kPanelW - 0.01
innerX   = kPanelX + kPad
moveTop  = kBelowBar0 + 0.013
handsTop = moveTop + 2 * (moveW + moveGap) + 0.016

Move the control panel and all of that has to be re-derived. The same shape recurred inside the widgets themselves: CharacterPanel::BarsRect carried the comment "kept in sync with Draw's bar layout" — a hit test duplicating a draw layout, which is the symptom, not the disease.

The clearest evidence of the missing parent was the party-bar scale slider. Growing the bar had to push everything under it down, so ApplyPartyBarScale kept a side vector of every widget below the bar together with its scale-1 Y, and patched bounds.y on each one. With a real parent that is one container's rect changing.

Scale of the problem: 21 Widget subclasses, 15 UIContext instances.

The decision

Give Widget children, and put the tree walk in the base class once.

Layout, Update, Draw and DrawOverlay became non-virtual and own the recursion. A subclass overrides LayoutSelf / UpdateSelf / DrawSelf / DrawOverlaySelf and handles only itself. A container therefore cannot forget to visit its children, and the walk order is fixed in one place rather than re-decided per widget:

  • Layout self, then children — each against this widget's ContentRect().
  • Update children in reverse add order, then self, so the topmost child owning a pixel claims the mouse before its parent's own hit test sees it.
  • Draw self, then children in add order — painter's order, parent behind.
Why drawing and input walk the tree in opposite orders Drawing visits a widget then its children in add order, so a parent paints behind the children stacked on top of it. Input visits children in reverse add order before the parent, so the topmost child owning a pixel claims the mouse before its parent's own hit test runs. Reversing either order would make a panel paint over its own button, or swallow that button's click. ONE TREE — Panel, with a Button added to it DRAW — self, then children in add order Panel 1 Button 2 then painter's order: the parent ends up BEHIND the children stacked on top of it UPDATE — children in REVERSE, then self Panel 2 Button 1 first cursor the topmost child owning a pixel claims the mouse before its parent's hit test runs Same tree, opposite orders — and neither is a preference. Reverse the draw and the panel paints over its own button; reverse the update and the panel swallows that button's click. LAYOUT is self-first, because a container sizes itself, clamps its scroll and assigns its children's bounds — so the recursion into the children has to see the final values.
Neither order is a preference. Reverse the draw and a panel paints over its own button; reverse the update and it swallows that button's click.
A flat sibling list becomes a control tree Before, every HUD widget was a direct child of the window root and its bounds were a fraction of the window, so a parent chain like panel to inner to hands row had to be multiplied out by hand at authoring time. After, each widget owns its children and their bounds are fractions of the parent's content rectangle, so moving the whole control bar is a single rectangle change. BEFORE — ONE FLAT LIST root — the window Panel Label Button HandSlot Spellbook Portrait Bars MessageLog 30-odd siblings, all at depth 1 kPanelX = 1 - kPanelW - 0.01 innerX = kPanelX + kPad moveTop = kBelowBar0 + 0.013 handsTop = moveTop + 2*(moveW+moveGap) every bound a fraction of the WINDOW — the parent chain multiplied out by hand move the panel, re-derive all of it AFTER — ONE TREE root — the window ControlBar bounds: 0..1 of root HandsArea 0..1 of ControlBar HandPair 0..1 of HandsArea HandSlot 0..1 of HandPair held item a child's bounds are fractions of its PARENT's ContentRect(), never the window move the bar → set one rect
The parent chain used to be multiplied out at every authoring site. Now it is resolved by the walk, from a window-sized root down.

ContentRect() is the hook that makes it general: the rect children resolve against. Padding, a tab's page inset, and a scroll offset each become one override instead of bespoke arithmetic. Two more hooks came with it — ChildActive for culling, ChildClip for clipping — and the clip is a stack: an inner clip intersects whatever is in force and restores it afterward, so a scroll area inside a scrolled page cannot widen its parent's clip.

UIContext grew a hidden root widget sized to the window and forwarded Add to it, so the existing flat authoring became "children of root" and kept working untouched. That mattered more than it sounds: it meant the conversion could be nine phases spread over a week rather than one commit that broke every screen at once.

The one genuinely new container is Repeater, for content whose count changes per frame — the status-effect strip, a list of rows, a grid of tiles. It grows a pool with a factory, places child N with an indexer, and hides rather than destroys the surplus, so nothing dies mid-frame and no pointer dangles. Repeated children hold an index and re-resolve against the model every frame, never a cached pointer into it.

An area is not enough

Six phases in, every widget had an area and the hand-multiplied chains were gone. Then an editor dialog drew its title over its first row and ran a checkbox label under its preview pane.

Neither is really a dialog bug. The dialog's bands were hand-authored fractions of the panel — kTitleH 0.12, kFacingH 0.14, kFooterH 0.14 — which is a guess at how tall a title is, and the guess broke the day the title face grew. A 52px title in a 45px band is the first symptom; a checkbox given a third of a column that is itself 60% of the panel is the second. Nudging those fractions fixes that dialog and leaves the next one to be found by eye.

So the fix is the mechanism. ui::Stack removes the possibility instead of policing it: rows are added in order and their positions are computed in LayoutSelf — which is the moment the font, and therefore rem, is known, the thing a build-time fraction could never see. Two rows of one stack cannot overlap at any window size, in any language, at any font scale, because no authoring site ever writes their coordinates. What a site does write is how much room a row needs, which is the part a human can get right.

Authored bands versus computed rows On the left a dialog's bands are authored as fractions of the panel, which is a guess at how tall a title is; when the title face grows, the text overruns its band and lands on the row below. On the right the same rows are added to a stack with typographic extents and their positions are computed at layout time, when the font size is finally known, so two rows cannot overlap. AUTHORED FRACTIONS written once, at build time kTitleH 0.12 dust density haze ambient ambient scale kFooterH 0.14 Save Level settings a 52px title in a 45px band — the guess broke when the face grew nudge the number, fix one dialog ui::Stack computed in LayoutSelf, per frame Level settings Fixed(2.0) dust density Fixed(1.75) haze ambient ambient scale Fill(1) Fixed(1.75) Save a row says how much room it NEEDS, in rem — never where it goes two rows of one Stack cannot overlap
A row asks for an extent; the container decides where it goes. The left-hand failure is not a bad number — it is a number that was never knowable at build time.

Two behaviours the class needed before it was worth trusting:

  • It shrinks rather than overruns. If the fixed rows want more than the stack has, they all scale down. Overflow is the one outcome the class exists to prevent, and it is invisible to a sibling check: a row past the end lands on whatever the parent put after the stack, and those two are not siblings.
  • fitContent inverts the relationship for the inside of a scrolling page, where the content is meant to be longer than the box. The stack measures its rows and writes that extent back into bounds — which is exactly what a scroll area reads to size its scroll — then lays the rows out against the measurement, so they are right on the frame they are measured on. Writing a measured size back into a fraction means knowing what it was a fraction of, which is why Widget::ContainerRect() exists.

Stacks nest, so a row can itself be a horizontal stack, and a table's columns line up by construction rather than by four x-constants agreeing with each other.

Two kinds of unit

Layout is parent-relative. The detail inside a control is not, and should not be. Padding, a row's height, a scrollbar's width, a thumb's minimum grab size — those belong to the text they sit beside, not to whatever rect happens to contain them. A checkbox's label gap should not stretch because the row it was dropped into is wide, which is precisely what a naive "everything is a fraction of the parent" gives you.

So, the CSS model: 1rem = the context's root font size. Every UIContext is its own document with its own root — the HUD's 17px, the menus' 28px, the sheet's 22px — and all of them already track the window height, so anything in rem scales with the UI for free, at any resolution, with no second scaling rule. One line of text is 1.25rem; a row holding one line with air around it is about 1.75rem. Stack's Len::Fixed(n) is n rem for this reason.

em is the widget's own font size, and it really differs from rem, because a widget can set a fontRole and draw in another typeface. The role inherits down the subtree exactly like CSS font-family — unset means "whatever my parent resolved" — and it resolves before LayoutSelf, so a container that sizes itself from its own text measures in the face it will actually draw in.

What moves when the window, the size, or the face changes Row height and padding are expressed in rem, the context's root font size, so they grow when the window grows. A font scale set on one widget moves only that widget's own text, because it moves em and not rem. A font role set on a container changes the typeface throughout its subtree and moves no geometry at all. ONE ROW, THREE CHANGES WHAT MOVED baseline Ambient scale 0.60 window grows → rem grows Ambient scale 0.60 row height, padding, checkbox, text — all of it 1rem = the context root fontScale on the label → em grows Ambient scale 0.60 that label's text only rem is untouched, so the row does not re-space fontRole on the container → face changes Ambient scale 0.60 nothing. Not one pixel. inherited like CSS font-family rem is the document's grid and must not move when a widget changes face — otherwise re-facing one label re-spaces its neighbours.
The same row under three changes. Only the first is supposed to move geometry, and only the first does.

The split is load-bearing, and it is the reason the seam was given a name before it was needed: rem is the document's grid and must not move when a widget changes face. Padding and row heights stay in rem, so re-facing one label cannot re-space the controls around it; detail belonging to a widget's own text is in em, so it tracks the face beside it. Demonstrated by setting a single fontRole on a menu context's root: every menu item re-faced, and no geometry moved by a pixel.

The only raw pixels left in the project are hairlines — the 1px borders and the 2px text caret — because a hairline expressed as a fraction either blurs across two rows of pixels or vanishes.

The check

ui::Stack makes sibling areas disjoint. It does nothing about a widget that draws outside the area it was given, and that is a real category: a Label draws its whole string from its top-left corner however narrow its bounds.

So Widget::InkRect() is what a widget paints, where Pixel() is what the layout gave it. Without that distinction a check would compare the two rects, find them disjoint, and miss the exact bug that prompted the work.

Dev console uioverlap arms a two-frame audit. It hooks into UIContext::Render, so every context that renders is covered with no per-caller wiring — the HUD, the pages, and whichever dialog happens to be open — and it reports two things: siblings whose ink intersects, and any child whose ink escapes its parent's ContentRect.

Ink rectangle versus pixel rectangle, and the two findings the audit reports A widget's pixel rectangle is the area the layout gave it; its ink rectangle is the area it actually paints, which is larger whenever drawing is measured rather than bounded, such as a label whose string is wider than its row. The audit compares ink rectangles and reports two things: sibling widgets whose ink intersects, and a child whose ink escapes its parent's content rectangle. A parent that clips its children is exempt from the second. WHAT THE LAYOUT GAVE IT, AND WHAT IT PAINTS parent ContentRect() Pixel() InkRect() sibling Ambient scale (per level) +26px a row the stack could not shrink far enough A Label draws its whole string from its top-left corner, however narrow its bounds — so ink is not pixels, and a check that compared pixels would pass both of these as disjoint. uioverlap REPORTS 1 · SIBLINGS whose ink intersects hud > BelowBar [0,0 1600x900] overlaps PartyBar [0,0 1600x148] an area claimed and not drawn in 2 · A CHILD that escapes its parent type-editor > Slider [.. 300x28] escapes its parent by 26px it lands on something with a different parent — no sibling check compares them NOT A FINDING A parent that CLIPS is exempt from the escape check — a scroll area's children are meant to run past it, and the clip means the excess paints on nothing. Adjacent rows sharing an edge exactly are not an overlap either. That exemption is the difference between a check people trust and one they learn to ignore.
Ink versus pixels, the two findings, and the exemption that decides whether anyone trusts the output.

The second finding matters as much as the first, and it was added only after a stack quietly let a row run past its end onto the parent's Save button. A row that overflows lands on something with a different parent, which no sibling check would ever compare.

The exemptions are where a check like this lives or dies. A parent that clips is exempt from the escape check, because a scroll area's children are meant to run past it and the clip means the excess paints on nothing. Adjacent rows sharing an edge exactly are not an overlap. overlapOk opts out the deliberately layered. Get those wrong and people learn to ignore the output, at which point the check is worse than nothing.

A widget must not claim a pixel it does not paint

The tree answered whose area a widget's is. Two later defects turned out to be the same question asked about input, and both are worth stating because neither would have been found by looking at the screen.

The two input claims, and input clipped like drawing On the left, a slider under the cursor claims the pointer so a click cannot fall through to the page, but it does not claim the wheel, which passes to the scroll area that can act on it. On the right, a tile straddling a scroll area's clip boundary is hit-testable only where it is still painted: the visible part takes the click, the clipped part leaves it to whatever is above. THE POINTER AND THE WHEEL ScrollArea — the settings page Party bar scale Background opacity Theme colours pointer → Slider wheel → scrolls the page The slider claims the POINTER so a click cannot also land behind it — and has no use for the wheel. One boolean for both is why the settings page would not scroll over most of its own surface. INPUT IS CLIPPED LIKE DRAWING the search box lives up here ScrollArea clip tile not hot clipped away clickable still painted One widget, one frame — only the painted part is hot. The picker's top tile row scrolled up UNDER the search box and kept hit-testing, so the box could not be focused. ChildActive covers a scroll area's DIRECT children; only the clip knows where a grandchild really shows. One rule, seen twice: a widget must not claim a pixel it does not paint.
Two bugs, one rule. A control that wants the click has no use for the wheel, and a widget scrolled out of sight has no business taking a click.

The wheel is its own claim. ConsumeMouse meant "I am using the mouse", and a Slider calls it on hover — correctly, so a click cannot also land on what is behind it. But one flag gated the wheel too, which a slider has no use for at all. The settings page is mostly sliders, so the wheel did nothing over most of it, and the two tabs that actually need scrolling were the worst affected. ConsumeWheel is now a separate claim: a widget that scrolls takes it, a widget that merely wants the click does not, and a modal takes both because an open popup has to freeze what is behind it whether or not it scrolls.

The pointer is claimed by whoever is under it; the wheel by whoever can act on it.

Two different questions, and answering both with one boolean is how a slider came to own a gesture it ignores.

Input is clipped like drawing. A scrolled subtree is full of widgets whose rects run past the window they are seen through, and every one of them was still hit-testing. In the asset picker, the grid's top row sat under the search box and ate its clicks, so the box could not be focused. ChildActive covers a scroll area's direct children; a row inside a stack inside the area is a grandchild, and only the clip knows where it really shows. Widget::Update now carries the draw walk's clip stack in two parts — skip a widget clipped away entirely, and suppress the pointer through a subtree the cursor is outside the clip of, which is what keeps the visible half of a half-clipped tile clickable while its hidden half is not.

What it cost

A mechanical rename across 21 subclasses, and the discipline to keep the phases separable — the flat-authoring compatibility shim was what made that affordable, and it existed for no other reason.

The child-first input rule needed an escape hatch immediately. A character slot highlights as one piece, but its children cover most of it and claim the mouse first, so asking "am I hovered?" after they run reads no. UpdateBeforeChildren gives a container its first look while consumption still reflects only what lies outside its own subtree. Claiming the mouse there takes it from the children, so only a modal should.

A scroll area measures overflow from its own children's bounds — which sounds obvious and is not, once a Repeater is involved. The character sheet's lists spilled past the panel with no scrollbar, because the repeater's own bounds stayed {0,0,1,1} and the area therefore saw no overflow at all. The rows behind it are grandchildren. Any repeater inside a scroll area has to be sized to its stacked content.

A content-sized stack writes its height during its own layout, which runs after the scroll area above it has clamped the scroll for the frame. Setting a scroll before that clamp clamps it to zero against a grid the area does not yet know is tall — so restoring a scroll position across a rebuild, and scrolling a selection into view on open, both have to happen after the layout, not during the build.

Rebuilding a subtree destroys what is in it, and a search filter changes on every keystroke. The first character landed and the rest went nowhere, because the field being typed into was destroyed and rebuilt under the cursor. Only the grid's rows are refilled now — which needed Stack::ClearRows, because ClearChildren alone leaves the parallel extent list behind and the refilled stack lays its new rows out to the old ones' sizes.

Self-clipping widgets were dropping their ancestors' clips. Three of them bracketed their own content with SetScissor(&rect)SetScissor(nullptr), and that bare reset is exactly the bug the clip stack exists to prevent. ScopedClip intersects and restores, so the draw walk and self-clipping widgets share one mechanism.

An intention in a design doc is not a check. Three of the four defects the final sweep found were placeholder bounds that earlier phases of the plan had explicitly promised to fix, and hadn't. The plan document said, in writing, that a container spanning the whole window would get a real rect two phases later. It did not, and nothing noticed for six days.

What it bought

  • ApplyPartyBarScale went from a side vector of every widget under the bar to setting two rects. The saved-Y list is gone.
  • One scroll implementation instead of five. The tab control, the sheet's lists, the save-slot list, a dropdown's popup and the asset grid each had their own scroll offset, thumb maths and clip. They are all ui::ScrollArea now, and the thumb-drag arithmetic exists once.
  • Ten editor dialogs share one card. Each had been authoring the same five window fractions by hand — panel, title, content, footer, and a close box floated into the corner — every one an independent guess at where the one above it ended. Roughly 40 hand-authored rects went with them, and so did the raw draws: six info rows, five discovered-map rows, two preview panes and their headers were all furniture the layout could not see, which is why a checkbox could end up beneath a pane.
  • Nothing in the project hand-places rows any more. The settings page's Flow — a build-time cursor with CSS collapsing margins, good of its kind — was the last one out. It placed rows in fractions of the page, so every height was written twice: labelH = 0.057 of a 0.55-tall page is 28px, which is one line of the menu font. The page said "5.7% of the tab" where it meant "one line of text", and the two could only agree by hand.
  • Chrome scales with its type. Verified at 2560×1440: the checkbox that used to cap at 18px beside 45px text now scales to 29px, and the slider thumb, colour swatches, scrollbar and popups keep their proportions.
  • Eight real defects, none of which had been reported by anyone. Four when the audit first landed, four more in the sweep at the end.

How it's proved

Two console commands, and one thing they do not do.

uitree outlines every widget in every context tinted by depth and names the chain under the cursor; uitree dump <context> prints the tree indented with each widget's pixel rect and bounds fractions. That is what the conversions were verified against instead of pixel-hunting screenshots — the control-bar phase's claim was "same pixels, new structure", and it is checkable because every pixel rect in uitree dump hud was byte-identical before and after.

uioverlap is the invariant check, and the last act of the thread was to actually run it everywhere: 26 screens — the landing menu, all five settings tabs, the HUD, the character sheet in each of its five modes, the pause menu, the save-slot page, the editor map, four instance inspectors, the level and balance dialogs, the type editor's four tabs, the monster config, the asset picker and the creation dialog. Twenty-two came back clean. The four that did not were all the same defect — bounds that overstate what a widget draws — including one button authored at x 0.5379 with width 0.4773, which is 1.0152, three pixels out of its own plate. It taking an optional label and writing to the log is what made a scripted sweep collectable rather than 26 screenshots.

What neither command does is run without me. There is no gate: uioverlap covers whatever is on screen when someone types it, which makes it a very cheap habit rather than a check. And it proves areas are disjoint, not that a screen looks right — a layout can pass cleanly and still be ugly.

Still open

  • No gate. The audit is one console line with no wiring, which is the reason it gets run at all, and also the reason a regression could sit for a week between sweeps. Driving all 26 screens headless and failing a build on a finding is buildable — the game already scripts its own input for screenshot verification — and has not been built.
  • The spellbook's interior is still one widget. Its rects are already parent-relative fractions of its own pixel rect, so it breaks no rule, but the symbol grid and the sequence row lay themselves out. Splitting them would couple four classes back through the parent for one tight state machine — which runes are available, a click truncating the sequence tail — and would remove no bug. Worth doing alongside a change that actually wants per-rune widgets, and not before.
  • The dialogs are stacked but shallow. Every one of them has a computed card and no hand-placed rows, but a dialog's rows are children of a stack rather than of meaningful sub-containers. The structure would not move a constant anywhere more meaningful, so it has not been done — the same trade the character sheet's inventory body makes.
  • The map editor and the dev console are not widgets at all. They draw straight to the sprite batch with hand-rolled hit tests, including their own hover identity tracking across the window-pixel/device-pixel split. They are the largest remaining surfaces outside the tree, they get none of this, and converting them is its own thread.
  • Ink is declared, not measured. InkRect() is an override a widget author has to remember, and today only Label and Checkbox implement it. Any other widget that draws past its bounds is invisible to the audit — which means the check's coverage is exactly as good as the last person's attention, and that is the weakest joint in the whole thing.

Landed

August 2026 · ten phases in a week

Topics

Layout Retained-mode UI Composite rem/em Invariants Dev tooling

View source ↗
← Back to Dungeon