0004: Canvas overlays go through a pointer-anchored host, CanvasPopup
Status
Accepted (2026-09-27).
Context
Commit 4b8e580 fixed one report of the Get/Set chooser opening away from the mouse — dragging a
variable onto the canvas — by adding GetSetChooserVM.ScreenPosition (an Avalonia.Point, set by
NodeGraphVM.Drop(MemberVariableVM, GraphPoint, Point)) and binding GetSetPopup's
HorizontalOffset/VerticalOffset to it in GraphEditorView.axaml. That fix was per-caller: the
other path that opens the same popup, SuggestionListVM.SelectAsync's case VariableSpecifier
(picking a property/field from the member search, e.g. after dragging from an object pin), never set
ScreenPosition, so it opened at the origin instead. The owner reported this as the same bug
recurring under a different trigger.
Root cause: Placement="Pointer" reads Avalonia's own last-pointer-position tracking, which is
internal and is not kept current by drag-and-drop (DragEventArgs, not PointerEventArgs). The
per-caller fix threaded a screen coordinate through the view model (GetSetChooserVM.ScreenPosition,
the 3-arg Drop overload) to work around this, which put a view concern (screen pixels) in the view
model and had to be repeated for every new opening path and every new kind of popup.
Decision
Positioning a canvas overlay is a view concern, solved once, centrally:
CanvasPopup(src/NetPrints.Editor/Controls/CanvasPopup.cs), a sealedPopupsubclass, is the only way a canvas overlay opens. It usesPlacement="Custom"with aCustomPopupPlacementCallbackthat anchors the popup at the last pointer position tracked for its window, clamped so the popup's known size (CustomPopupPlacement.PopupSize) stays fully inside the window'sClientSize— not the screen, which can be much larger than the window. The anchor is frozen the moment the popup opens (recomputed only clamp-wise on further layout passes), so it does not chase the pointer while open.CanvasPointerTracker(same folder), one perTopLevel, listens forPointerMoved,PointerPressed,DragDrop.DragOverEventandDragDrop.DropEventat the top level and remembers the last position in that top level's coordinates. The drag events are exactly what Avalonia's ownPlacement="Pointer"tracking misses, and what caused both bug reports.Invalidate()clears the tracked position, for a keyboard-triggered open that had no pointer involvement.- If no pointer position is tracked,
CanvasPopupraisesFallbackPositionRequested; the host view supplies a window-relative point (inGraphEditorView, the selected node's position, or the canvas center if nothing is selected). The same "selected node, else canvas center" rule also picks the graph-space position for a keyboard-triggered node search (Ctrl+Space). CanvasPopupalso centralizes the behaviorNodeSearchViewandGetSetChooserViewused to duplicate or omit inconsistently: Esc closes it (aKeyDownhandler onChild, attached only while open); light-dismiss on an outside click (IsLightDismissEnabled, built intoPopup); focus moves to the first focusable descendant ofChildon open, and returns to whatever had focus before the popup opened; and only oneCanvasPopupis open perTopLevelat a time (opening one closes any other already open for the same window).SearchPopupandGetSetPopupinGraphEditorView.axamlareCanvasPopups. The screen-coordinate plumbing this replaces is gone:GetSetChooserVM.ScreenPosition, the 3-argNodeGraphVM.Drop(MemberVariableVM, GraphPoint, Point)overload (now 2-arg, graph coordinates only),NodeSearchView's own Escape handling, andGetSetChooserView's pointer-exit-to-close handler.- Every pointer-related canvas overlay, today and future, goes through
CanvasPopup: the node, pin, canvas and connection context menus (P6, and P3a's context-menu contribution point); connection, node and pin tooltips with designer docs (P3a, ADR-0002); inline editors (event graph/entry rename, pin default value, variable rename); choosers like Get/Set, cast/conversion and override pickers.NoRawPopupOutsideCanvasPopup(SourceHygieneTests) enforces this: nosrc/**/*.axamlfile may declare a raw<Popup. - The command palette and go-to-anything (P3a) are explicitly out of scope: they get one shared centered host, not pointer-anchored, since "near the last click" is not a useful position for a global command search.
- Dialogs stay centralized in
EditorDialogs, owner-centered: a modal dialog answers a question the user asked deliberately, not something that should open where their pointer happens to be.
Consequences
- A new canvas overlay gets pointer anchoring, Esc, light-dismiss, initial focus and "only one open"
for free by using
CanvasPopup, instead of re-deriving screen coordinates or re-implementing lifecycle behavior per popup. - View models keep only graph-space positions (
GraphPoint); no view model holds anAvalonia.Pointor any other screen/pixel concept. CanvasPopup's anchor is frozen per-open from whicheverCanvasPointerTrackerposition was current when it opened; a popup that must visibly track the pointer while open (none exists today) would need a different control.- The hygiene test only catches a raw
<Popupelement in.axaml; a popup constructed purely in code (new Popup()) would not be caught. No such construction exists insrc/today.