Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

140. A widget may reach its window

Date: 2026-08-19

Status

Accepted. Answers the gap ADR-0100 left open — “an overlay cannot be raised from inside the tree” — and narrows ADR-0106 rather than reversing it.

Context

ADR-0106 is right and stays right: a menu is a widget and opening one is a call, because opening needs a Host — something to measure the panel, ask the platform for a window and close it again — and a widget is a value described afresh every frame. One holding the window it is drawn in would be describing its own surroundings.

select breaks the analogy at the one place it matters. Opening a menu is something an application does: a menu bar, a toolbar button, a right-click handler. Opening a dropdown is something the control does, and nobody else can — a user clicking a select has not asked the application anything, and a control that needed Selects.open(host, …) wired up per instance would be a control that does not work when a document writes it:

select bind="app.theme" change="app.pick-theme" { option value="dark" "Dark" }

There is no application code on that line to hold a host with.

Two shapes were already in the tree and neither fits. Tour takes a Host as a record component, handed in by Tours.start(host, …) — fine for a widget only Java builds, and impossible for one a document writes. And Wiring could have carried a host to the inflater; that puts a window in the thing whose job is resolving names, and would still leave a Java-built Select with no way to get one.

BuildContext is where the answer was, and ADR-0100 said so: “what it wants is Flutter’s Overlay.of(context)”. It was not built then because there was one consumer, and an interface designed against one caller is designed twice (ADR-0019). There are two now.

Decision

BuildContext.host() returns the window an element is being built into, as an Optional.

@Override
public Widget build(BuildContext context) {
    host = context.host().orElse(null);          // captured, not read
    return new SelectField(…, this::toggle, …);  // which uses it on a click
}

The window is held on the ElementTree and not on each element, because it is the same answer for every node in one tree and a different answer in a popup’s: new ElementTree(root, host). The launcher passes itself when it builds the application’s tree and when it builds a popup’s, so a select inside a menu opens against the window that owns the menu.

For acting, not for reading. BuildContext is documented as deliberately narrow — a build must be a pure function of its widget, its state and the context, so that anything reachable through it is something the framework can invalidate. A host does not fit that rule if a build reads from it: Host.anchor answers from the last painted frame, and nothing invalidates a build that depended on it. What a build may do is capture the host for a handler that runs later, which is outside the build entirely. The javadoc says so; nothing enforces it, for ADR-0136’s reason — mechanically enforcing a recommendation turns it into a rule nobody agreed to.

Empty is a normal answer, and the reason this is an Optional rather than a nullable getter. A widget test builds new ElementTree(widget) with no window behind it, and so does every golden image. A control that threw there could not be drawn at all, and one that silently did nothing would be indistinguishable from a bug — so the contract is stated: no window, no popup, and the control draws its closed form.

Consequences

A widget can now open a platform window, and must close it. A popup is not a value and is not collected with the tree, so a state that opens one closes it in dispose() — otherwise an element that goes away leaves a window parented to nothing. That is the first resource a widget in this toolkit owns, and it is why SelectState has a dispose at all.

A press that dismisses a popup no longer also activates what it hit. This fell out of the first control to open its own popup and is a general rule the toolkit was missing: with a list open, the press on the field that dismisses it must not also be read as “open it”, or the control toggles twice and never closes. The launcher already took the press for the secondary button (ADR-0108); it now takes any press that actually closed something, which is what every desktop does — the click that puts a menu away does not also press the button underneath it.

Popup.focusOn(String) exists, because a control that has already chosen opens on the row it chose. See ADR-0141.

Menus does not change. Opening a menu is still an application’s call and still takes a host explicitly, because that is what a menu bar and a context menu actually are. What has changed is that a widget which must open something for itself now can, and toast, dialog, date-picker and autocomplete all want exactly this door.

A widget that reaches for host() when it wants an ancestor is doing it wrong. findAncestorState is still how a descendant asks the thing it is inside for something (ADR-0120); this is for the case where the answer is not in the tree at all, because a second platform window is not in anybody’s tree.