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.
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.
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.
fitContentinverts 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 intobounds— 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 whyWidget::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.
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.
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 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
ApplyPartyBarScalewent 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::ScrollAreanow, 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.057of 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 onlyLabelandCheckboximplement 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.