270. A popup is placed again when its window moves, and when its anchor does
Date: 2026-09-05
Status
Accepted. Finishes ADR-0231, whose resize half shipped and whose other two halves were left as a TODO entry naming what each of them needed.
Context
The entry was precise about what was missing, which is what made it answerable:
A window that moves does not re-clamp its popups, and a scrolling anchor does not drag one. The resize half is built […] A window move is not an event: there is no
BackendEvent.Moved, so re-clamping a menu that was flipped against the work area at the old position needs an SPI event, an SDL translation and a fabricated-event test of ADR-0061’s shape. And apopoverfollowing a scrolling anchor needs the anchor to report that it moved, which isLocated’s shape and a widget-level wiring rather than a window-level one.
Both halves are the same sentence — put the popup back where it belongs — and they are missing for two different reasons. A move changes nothing about the anchor and everything about the screen: a popup is placed as an offset from its owner, so dragging the window carries the menu along, and the only thing that moved underneath is the work area’s position in the window’s own coordinates. A menu flipped above its button because there was no room below it has to be asked the question again at the new position, and nothing was asking.
A scroll is the mirror image. The window is where it was, the work area is where it was, and the widget the menu hangs off is drawn a hundred pixels higher than it was last frame — because a scroll is a translation on the content and Yoga never sees it (ADR-0114, ADR-0116).
Decision
A move is an event, because a move is not a resize
BackendEvent.Moved carries the window and its new position in the desktop’s
logical coordinates. SdlEventType.WINDOW_MOVED is 0x205, and like every
constant in that enum it is checked against the compiled SDL by the layout probe
— which caught it being unregistered in goldberry_shim.c before anything else
did, exactly as SdlEventType’s own javadoc promises it would.
Three things fall out of it being a separate case rather than a flavour of
Resized:
- No repaint follows one. The frame on screen is still correct: nothing
inside the window moved.
Window.handleMovedtherefore does not callrepaint(), which is the one line that distinguishes it fromhandleResize. - The re-placement happens immediately, not after the next paint. ADR-0231
had to defer the resize case because
anchor(id)answers from the capture the last paint produced, and during a resize handler that capture is the old window’s. A move produces no new capture and invalidates none: the current one is the right one, and waiting for a paint would mean waiting for an unrelated frame that may never come. - The position is read off the window, not out of the event, for the reason
the sizes already were: one place asks the platform, and
position()is what every other caller uses.
Sdl3Window.movedTo deduplicates, because SDL sends WINDOW_MOVED for every
pixel of a title-bar drag and again for a move that put the window back where it
was. What a move costs above the SPI is a re-placement per open popup.
The event watch is deliberately not extended to it. ADR-0060 has the watch draw during a resize drag because the contents change while the platform’s modal loop is running; during a move drag they do not, so the queued events are enough and a popup is re-placed once the drag ends rather than per pixel of it.
A scrolling anchor is a frame, not a report
The entry proposed Located, and the anchor does not need it. Host.anchor(id)
already answers from HitTest.capture, which is taken every frame — so the
question “where is that widget now” has a fresh answer on every frame without
anybody reporting anything. What was missing was somebody asking it.
So replacePopups runs at the end of any frame in which a popup is anchored by
id, alongside the two events. Anchored by id and not by rectangle, which is the
whole of the guard: a popup opened against a rectangle a caller computed has
nothing to re-resolve — the rectangle is all there ever was — so a window with no
id-anchored popup open pays one field read per frame and nothing else.
Located would have been the wrong shape twice over. It reports on a change,
which is one frame after the change for the first frame of a scroll; and it would
put the wiring on the anchor, so every widget an application wants to hang a
popover off would have to opt in. The popup is what wants to follow, and the
popup is where the wiring now is.
An anchor rectangle is the painted one
This is the part that had to change rather than be added, and it was wrong before anything scrolled.
HitTest.Region has two rectangles. bounds() is what layout produced;
painted() is where the box was drawn, which differs exactly when something
above it was transformed. Both javadocs said, in as many words, that a popup
anchors to bounds() — a menu belongs under where its button sits in the flow.
A button inside a scroll sits in the flow four hundred pixels below the
viewport it is drawn in. Anchoring to the flow rectangle opens its menu four
hundred pixels away from it, and this was true on the day a menu was first opened
from a scrolled list — following the anchor afterwards would only have kept it
faithfully in the wrong place.
So Launcher.anchor, Popup.anchor and Menus.open all read painted(). For
every box nothing transformed the two rectangles are identical, which is nearly
every anchor there has ever been and the reason this was not a visible bug
sooner. tour had already reached the same conclusion for its veil
(ADR-0123) and named the popup rule as
its contrast; that note now says the two agree.
Alternatives considered
- Re-placing every popup on every frame. Simpler by one method, and it moves popups that have nothing to follow: a rectangle a caller computed is not a question with a new answer, and re-clamping it per frame is a window that can drift under a control that never asked to move.
Locatedon the anchor. What the entry proposed. One frame late at the start of a scroll, and it makes following a property of the widget being anchored to rather than of the popup that wants to follow.- Re-opening the popup rather than moving it.
Popup.moveexists for this and is cheaper: the tree stays mounted, the keyboard stays where it is, and nothing flickers. ADR-0231 settled this and it has not changed. - Treating a move as a resize. It would repaint the window for a move, which is a full frame per pixel of a title-bar drag for a picture that did not change.
Consequences
- A menu re-clamps when its window is dragged near a screen edge, and a
popovertravels with an anchor that scrolls under it. §7’s “placement with flip/shift when near edges” is now a promise about where the window is rather than about where it was when the popup opened. BackendEventgained a case, so every exhaustive switch over it had to say what it does with a move — which is what that interface being sealed is for.- The headless backend can produce one.
HeadlessWindow.moveTonow posts the event a window manager would send, so the whole path is reachable in CI, and the SDL translation has a fabricated-event test of ADR-0061’s shape under thedummydriver. - A
popoverfollows; amenuand aselectdo not. Following is a property of having been opened by id, andPopoveris the widget that is —host.popup(new Popover(items), "menu-button", Placement.BELOW)is its documented shape, and it is the widget the entry named.MenusandSelectStateresolve their anchor to a rectangle themselves because they need a minimum width and aFitas well, and there is noHostoverload that takes all three. Giving them one is a small piece of work nobody has asked for; it is inTODO.mdrather than done here on the guess. - A popup whose anchor scrolls out of sight follows it out of sight, clamped
to the work area rather than dismissed. Whether it should instead close is a
behaviour decision nobody has asked for; it is in
TODO.mdrather than guessed at here.