179. A popup says what it measured
Date: 2026-08-23
Status
Accepted. Closes two entries in book/src/TODO.md — the menu that capped itself
by estimate (ADR-0118) and the
select whose list was clamped rather than scrolled
(ADR-0141).
Context
Host.popup(content, anchor, placement) has always been three steps, and
Host’s own documentation has always said so: measure, place, open,
“and each is separately observable”
(ADR-0104).
The first of the three was not observable at all. The facility measured the
content, handed the number to Placement, and opened; a caller that needed to
know how big its content came out had no way to ask. Placement then clamps
anything taller than the work area to the near edge — which keeps the top
visible and silently drops everything below it.
Two callers needed the number and neither could have it:
Menusguessed. It decided whether a menu would be taller than the screen from its row count times an assumed 34px, and wrapped it in ascrollif so. The guess rounded up so that it erred towards wrapping a menu that would have fitted rather than clamping one that would not — an invisible viewport, but a thumb and a wheel handler with no business being there.--gb-menu-item-heightis 32, so the number was also kept in two places, one of which is a stylesheet a widget cannot read.selectdid not try, and a list with more options than the display is tall lost its bottom. The same defectmenuhad before ADR-0118, still shipping in the control §3 most expects to be long.
One guess and one gap, with one cause.
Decision
The facility reports, between the measure and the place
A new overload takes a Host.Fit: a callback handed what the content measured
and the rectangle it has to fit inside, which answers with the content to open.
host.popup(list, field, Placement.BELOW, width, new Fitted("select-viewport"));
Returning the same widget is the ordinary answer and costs nothing. Returning anything else costs a second measurement — the element tree is thrown away and rebuilt, because everything measured against the old content is worthless. That is the right way round: nearly every popup fits, and only the one that did not pays.
The cost is worth stating plainly, because
ADR-0104 went out of its way to build
the tree once (“a second one would also be a second lot of initState”). It
still is, for every popup that fits. A popup that does not fit is being rewritten
by its caller, and a tree built from content nobody is going to open is not worth
keeping.
The facility asks rather than deciding
Two reasons, and neither is new:
- Whether content that does not fit should scroll or be clamped is a fact about the content. A menu that lost its last three commands is the worst kind of wrong; a tooltip that scrolled would be absurd, and would rather have been a dialog (ADR-0118).
:corecould not act on the answer anyway. A viewport is a widget and:corehas none (ADR-0092).
So the reporting is in :core and the policy is in :widgets, which is the
split the module fence already draws.
One policy, held once
Fitted is the answer both callers give, in widgets.core.scroll beside the
Scroll it builds: content taller than the room becomes a viewport of the room’s
height, and content that fits comes back untouched.
The 8px margin it keeps at each end came out of Menus and was never anything to
do with menus — a panel flush against the top and bottom of the screen looks like
one that has been cut off even when it has not. Two viewport classes rather than
one (menu-viewport, select-viewport) so that a stylesheet can tell them apart
without either inheriting the other’s future; both are flex-grow: 0 today.
Consequences
ROW_ESTIMATEis gone, and with it the second copy of--gb-menu-item-height. The end-to-end test measures a twenty-row menu at 667px where the estimate said 696 — close enough that the estimate was never wrong on a full-height display, and 29px of a menu that had to be needlessly wrapped on a short one.- A
selectlist longer than the screen scrolls, which is the user-visible defect this was written for. Placementstill clamps, and that has not changed: a caller that opens an oversized popup and offers noFitgets the old behaviour, which is right for a facility that cannot know what its content means. What changed is that the two callers who could know now have the number they need to say so.Hostgained a fifthpopupoverload, which is one more than a surface this wide wants. The alternative was a standaloneHost.measure(Widget), and it was refused on cost: it would build a throwaway element tree on every popup — runninginitStatetwice for every menu and every dropdown — to serve the rare case where the answer matters. A callback pays only when the answer is acted on.TestHostconsults theFit, driven by ameasuring(width, height)knob, because aFit’s only observable is the widget its caller decided to open and a test without a window has nothing to measure with. Unset, noFitis consulted and every test that predates this sees what it saw before.