ADR-0104: A popup is measured, then placed
- Status: Accepted
- Date: 2026-08-18
- Relates to:
docs/core-widgets.md§7,docs/ARCHITECTURE.md§4 and §7.2, completes ADR-0103, uses ADR-0080’s finding about where geometry lives
Context
ADR-0103 put a widget tree
in a popup window and listed what was still missing. Three of those items were
the same shape — the caller has to know something it cannot know — and they
are what docs/core-widgets.md §7’s popover is made of:
popover— anchored floating panel (theselectpopup generalized): placement with flip/shift when near edges, light-dismiss on outside click/Esc; the primitive under menus, dropdowns,date-picker,color-pickerand autocomplete.
- A size.
host.popup(content, at, size)made the caller supply one, and the showcase’s menu was180×132because somebody measured it by hand. Add an item and the number is wrong. - A position. The caller could anchor to a rectangle
(ADR-0103’s
Host.anchor) but nothing stopped the result opening off the bottom of the screen, or underneath a taskbar. - The keyboard. A menu with no focus in it answers
Downby doing nothing.
Decision
host.popup(content, anchor, placement) measures, places, and opens.
host.popup(new Popover(items), "menu-button", Placement.BELOW)
.ifPresent(open -> this.menu = open);
Measuring: two passes, and the second one is the whole record
RenderTree.measure(box, scale, availableWidth, availableHeight) lays a tree out
with no surface and reports the size it wants. Two floats rather than a
LogicalSize, because a size refuses NaN and “undefined” is exactly what has to
be expressible.
The trap is that Yoga lays a root out at exactly the available size when that size is definite. There is no parent for the root to be “at most” of, so a bound and a target are the same number. Measuring a menu against the window therefore returns the window — which happened twice, once per axis, and both times it looked like a placement bug:
- First against
window.size(): the menu came out960×640. - Then against
(windowWidth, NaN):960×108— the height was right and the width was still the window’s.
So the measurement is: nothing definite, which gives the content’s natural size; and only if that is wider than the window, a second pass with the width pinned, where a definite width is now what is wanted and a paragraph wraps at it rather than running off the side. A menu is a few dozen Yoga nodes and this happens once, when it opens.
The same trap bites a widget: Popover.render originally returned a growing box
so it would fill its window, and a growing root fills a definite available size.
It is content-sized now, and filling the window is not something it has to ask for
— the window was created at its measured size.
Placing: three rules, no state
Placement is a record of a preferred side, a cross-axis alignment and a gap,
with one pure function on it: anchor rectangle, size, and the rectangle it must
stay inside, in; a point and the side it ended up on, out. It opens no window,
reads no display and knows nothing about popups, which is why every case of it is
a test rather than a screenshot.
- Preferred side,
gapaway, aligned byalign. - Flip to the opposite side only if it does not fit on the preferred one and does fit on the opposite one. Not “if there is more room the other way”: a menu that changed sides on the strength of a comparison is a menu nobody can predict.
- Shift along the cross axis until it is inside — which keeps the popup attached to its anchor’s side while sliding it along. Flipping the cross axis would move it somewhere else entirely.
Still too big — a menu taller than the screen — and it is clamped to the near
edge, so the top of it is what survives. Making it scroll is scroll’s job and
scroll does not exist.
The rectangle it must stay inside is the display’s work area
Not the display’s bounds. SDL_GetDisplayUsableBounds excludes whatever the
desktop has reserved, and the difference between the two rectangles is exactly the
taskbar a menu would otherwise open underneath. BackendWindow gained
workArea() and position(); the launcher translates the first by the second, so
a placement policy works entirely in the window’s own coordinates — the same space
an anchor and a hit test are already in.
Both return Optional, because some drivers will not say and a headless backend
has no desktop. When either is absent the window’s own bounds stand in: a popup
kept inside its owner is always on the screen, which is a worse answer and not a
wrong one.
HeadlessBackend has a pretend desktop of 1920×1040 — 40 logical pixels
reserved at the bottom, so a test that confuses the work area with the display’s
size fails. The window can be moved about on it, because a placement policy is
only interesting near an edge and “near an edge” needs an edge.
The keyboard belongs to the open popup
A popup’s router focuses its first focusable node after its first frame — a menu
whose first item is not focused answers Down by focusing the first item, one
keystroke later than every menu anywhere else.
And keys the owner window receives are forwarded to the topmost open popup
before its own router sees them. Window.InputWatcher.keyPressed returns a
boolean now: true takes the key. This is not belt-and-braces — whether a popup
has the platform’s keyboard focus is per-driver (SDL gives a POPUP_MENU window
focus on some and not on others, and a tooltip must never have it), so without
forwarding an arrow key would move the selection in the window underneath the
menu on half the platforms.
Escape is taken by the watcher itself and closes the popup, which is the one key
that belongs to no widget.
popover is the panel, not the opening
The widget in :widgets is the surface: background, border, radius, padding, and
a class="menu" shape for items that fill the width. Where it goes and when it
goes away is Host.popup, and that machinery serves tooltip, select and
menu equally — none of which is a popover, so it does not live inside one.
Being a widget is what makes it themeable, density-aware and writable from a document; being only the panel is what stops three other widgets from having to be popovers to get placement.
Alternatives considered
- A
maxWidthon the box, measured once. The clean version of the two-pass measure, and it needsmax-widthin §8’s subset, which does not have it. Adding a CSS property to avoid a second Yoga pass over forty nodes is the wrong trade. - Placement inside
Popup. It would makePopupneed the work area, the window position and the anchor, which is three things it otherwise never touches — and would make the arithmetic untestable without opening a window. - Flip by choosing the side with more room. Predictable-looking and unpredictable in use: a dropdown near the middle of a tall screen would open upwards or downwards depending on pixels nobody is looking at.
- Let the platform place it. Windows and X11 both have menu-positioning conventions; SDL exposes none of them, and the three platforms disagree about flip behaviour. A toolkit that inherited that would have three menus.
- Move focus to the popup window and let the platform route keys. It is what the flags ask for, and it is not reliable: it depends on the driver, it is wrong for a tooltip by specification, and it makes “did my menu get the keys?” a question about the window manager.
Consequences
select,menuandtooltipare ordinary widget work now. Each still owns its own model, its item semantics and its keyboard map, and none of them has to solve size, position or dismissal.- A popup still cannot scroll. A menu taller than the work area is clamped and
loses its bottom.
scrollisdocs/core-widgets.md§1’s and unbuilt, and it is the one thing between here and aselectover a realistic option list. BackendWindowgrew two calls, bothOptional, andlibgoldberryexports two more SDL symbols.- Nothing re-places an open popup. Move the window with a menu open and the
menu stays where it was put —
Popup.moveexists, and nothing calls it. Apopoverthat follows a scrolling anchor is the case that will need it. - Two popups still do not know about each other, so a submenu chain is
menu’s to arrange: the launcher’s light dismissal closes all of them at once.