a context menu¶
open a small menu exactly where the user clicked, then dismiss it
by the end of this guide you will have a panel that opens a context menu at the exact pixel the user clicked. the menu shows the click coordinates, lists a few actionable items, and closes when the user clicks anywhere outside it or clicks an item. along the way you will learn how mouse events carry cursor position, how PositionIn converts that position into the right coordinate frame, and how a plain nullable field toggles the menu on and off with no manual rebuild call: goo requests a rebuild automatically once your event handler returns.
hold the menu state¶
the menu is either open or closed. two nullable fields describe that: a position and the button that opened it. when both are null the menu is absent; when they are set the menu renders at that spot.
public class ContextMenuUI : GooPanel<Container>
{
Vector2? _menuAt;
MouseButtons? _menuBtn;
protected override Container Build()
{
return new Container
{
Position = PositionMode.Relative,
Width = 400,
Height = 300,
BackgroundColor = Color.White,
};
}
}
Position = PositionMode.Relative on the root is load-bearing. absolutely-positioned children are placed relative to their nearest positioned ancestor, so without it the menu would escape this container and land somewhere unexpected.
these fields only ever change inside an event handler below, so plain fields are the right tool here. a field wrapped in State<T> (via Track()) is for state that changes outside a handler, a timer or network callback; that is not the case here, so wrapping these would just be ceremony.
capture the click¶
add OnClick to the root container. the lambda receives a MousePanelEvent. because OnClick sits on the root itself here, not on some child trigger, e.Target is the root, so e.LocalPosition is already relative to the root's own frame with no conversion needed; store it directly.
protected override Container Build()
{
var root = new Container
{
Position = PositionMode.Relative,
Width = 400,
Height = 300,
BackgroundColor = Color.White,
OnClick = e =>
{
_menuAt = e.LocalPosition;
_menuBtn = e.MouseButton;
},
};
return root;
}
no Rebuild() call is needed after the assignment. every GooPanel<T> requests a rebuild automatically once an event handler on its tree finishes running, so mutating _menuAt and _menuBtn here is enough on its own.
e.Target is the root itself, which is also the positioning ancestor for the menu, so e.LocalPosition already lands in the right frame. that only holds because OnClick is bound directly to root; if it sat on a child instead, Target would be that child and you would need e.PositionIn( root ) to convert into root's frame, as shown in events.
note: OnClick fires only for the left mouse button. OnRightClick and OnMiddleClick are separate handlers, unwired here, so _menuBtn (and the {btn} printed in the menu below) always resolves to MouseButtons.Left in this demo. wiring the same body onto OnRightClick (and OnMiddleClick) on root would let other buttons open the menu too, but remember SwallowClick only swallows the left-click chain, so a menu opened that way would need its own swallow story for those other buttons.
render the menu¶
add a Menu helper method and call it from Build() when _menuAt is set. the menu is an absolutely-positioned column; Left and Top are set directly from the stored position.
protected override Container Build()
{
var root = new Container
{
Position = PositionMode.Relative,
Width = 400,
Height = 300,
BackgroundColor = Color.White,
OnClick = e =>
{
_menuAt = e.LocalPosition;
_menuBtn = e.MouseButton;
},
};
if ( _menuAt is { } pos )
root.Children.Add( Menu( pos, _menuBtn ) );
return root;
}
Container Menu( Vector2 pos, MouseButtons? btn ) => new Container
{
Key = "menu",
Position = PositionMode.Absolute,
Left = pos.x,
Top = pos.y,
Padding = 12,
BackgroundColor = Color.FromBytes( 240, 240, 240 ),
BorderWidth = Length.Pixels( 1 ),
BorderColor = Color.Black,
FlexDirection = FlexDirection.Column,
Gap = 8,
SwallowClick = true,
Children =
{
new Text( $"clicked: {btn} at ({pos.x:F0}, {pos.y:F0})" ),
},
};
the if ( _menuAt is { } pos ) pattern unpacks the nullable in one step: when _menuAt is null the menu is absent; when set, pos holds the unwrapped Vector2 and the menu appears at that spot.
Key = "menu" gives this container a stable identity across rebuilds. the menu is added and removed from root.Children depending on whether _menuAt is set, so its position in the list is not fixed; the key lets the reconciler match it to the same instance run to run instead of guessing from position. see build method for the full diff mechanics.
by default, a click fires on the deepest panel under the cursor (the Target), then bubbles: it re-fires on each positioned ancestor's own OnClick in turn, all the way up to the root. SwallowClick = true stops that bubbling at this container, so a click inside the menu never reaches the root's OnClick and reopens the menu at a new position. see events for the full swallow and bubble mechanics.
dismiss on click outside¶
for "click outside to close", reach for Hud.DismissScrim: it builds a full-bleed invisible click-catcher over the nearest positioned ancestor, already carrying SwallowClick = true so its own click stops right there.
protected override Container Build()
{
var root = new Container
{
Position = PositionMode.Relative,
Width = 400,
Height = 300,
BackgroundColor = Color.White,
OnClick = e =>
{
_menuAt = e.LocalPosition;
_menuBtn = e.MouseButton;
},
};
if ( _menuAt is { } pos )
{
root.Children.Add( Hud.DismissScrim( () =>
{
_menuAt = null;
_menuBtn = null;
} ) with { Key = "scrim" } );
root.Children.Add( Menu( pos, _menuBtn ) );
}
return root;
}
the scrim takes Key = "scrim" because its sibling already carries Key = "menu": mixing keyed and unkeyed children in one list makes the reconciler abandon keys for the whole list (and warn in the console), so a list with any keyed child keys all of them. the same rule keys the menu header text next to the Each.Of items below.
the root keeps its OnClick unconditionally now, open or closed. that is safe because while the menu is open, every point inside the root is covered by either the scrim or the menu, and both swallow their own click before it can bubble back up to the root. Hud.DismissScrim swallows internally; Menu swallows via its own SwallowClick = true set in the section above. so the root's OnClick only ever fires when the menu is already closed, which is exactly when it should reopen one.
add actionable items¶
a real context menu needs more than one label; it needs a list of things you can click. add an array of action names and drop Each.Of into the menu's Children to turn each one into a clickable row that closes the menu.
static readonly string[] Actions = { "copy", "rename", "delete" };
Container Menu( Vector2 pos, MouseButtons? btn ) => new Container
{
Key = "menu",
Position = PositionMode.Absolute,
Left = pos.x,
Top = pos.y,
Padding = 12,
BackgroundColor = Color.FromBytes( 240, 240, 240 ),
BorderWidth = Length.Pixels( 1 ),
BorderColor = Color.Black,
FlexDirection = FlexDirection.Column,
Gap = 8,
SwallowClick = true,
Children =
{
new Text( $"clicked: {btn} at ({pos.x:F0}, {pos.y:F0})" ) { Key = "header" },
Each.Of( Actions, ( i, label ) => MenuItem( label ) ),
},
};
Container MenuItem( string label ) => new Container
{
Padding = 6,
OnClick = _ =>
{
_menuAt = null;
_menuBtn = null;
},
Children = { new Text( label ) },
};
Each.Of( Actions, (i, label) => MenuItem(label) ) runs MenuItem once per entry and adds each result as a child, auto-keyed by index exactly like Children.AddRange; Actions is a static, append-only list so the index key is enough. see dynamic children for the keyed-by-data form you'd want if actions could be added or removed at runtime.
clicking a row runs its OnClick, which clears _menuAt and _menuBtn, closing the menu; no Rebuild() call here either, for the same auto-rebuild reason as the root's own handler. MenuItem's OnClick presence also auto-gates its PointerEvents to All, so the row is clickable without declaring that yourself.
note: the actual copy/rename/delete behavior is left as a stub OnClick; wire it to whatever your app's selection state needs.
the complete class¶
here is everything together:
public class ContextMenuUI : GooPanel<Container>
{
static readonly string[] Actions = { "copy", "rename", "delete" };
Vector2? _menuAt;
MouseButtons? _menuBtn;
protected override Container Build()
{
var root = new Container
{
Position = PositionMode.Relative,
Width = 400,
Height = 300,
BackgroundColor = Color.White,
OnClick = e =>
{
_menuAt = e.LocalPosition;
_menuBtn = e.MouseButton;
},
};
if ( _menuAt is { } pos )
{
root.Children.Add( Hud.DismissScrim( () =>
{
_menuAt = null;
_menuBtn = null;
} ) with { Key = "scrim" } );
root.Children.Add( Menu( pos, _menuBtn ) );
}
return root;
}
Container Menu( Vector2 pos, MouseButtons? btn ) => new Container
{
Key = "menu",
Position = PositionMode.Absolute,
Left = pos.x,
Top = pos.y,
Padding = 12,
BackgroundColor = Color.FromBytes( 240, 240, 240 ),
BorderWidth = Length.Pixels( 1 ),
BorderColor = Color.Black,
FlexDirection = FlexDirection.Column,
Gap = 8,
SwallowClick = true,
Children =
{
new Text( $"clicked: {btn} at ({pos.x:F0}, {pos.y:F0})" ) { Key = "header" },
Each.Of( Actions, ( i, label ) => MenuItem( label ) ),
},
};
Container MenuItem( string label ) => new Container
{
Padding = 6,
OnClick = _ =>
{
_menuAt = null;
_menuBtn = null;
},
Children = { new Text( label ) },
};
}
press play, click anywhere on the white card, and the menu appears under the cursor showing the click coordinates and the three actions. click an action or click outside the menu and it closes.
what just happened¶
five ideas carried this whole build:
OnClickon any blob gives you aMousePanelEvent. the event carriesLocalPosition(cursor relative toTarget) andMouseButton(which button fired; since onlyOnClickis wired here, this is alwaysMouseButtons.Left).OnClicksits directly on root in this demo, soTargetis root andLocalPositionalready lands in root's own frame, no conversion needed. that shortcut only holds because the handler is on the positioning ancestor itself; when a handler sits on a child trigger instead, reach fore.PositionIn(ancestor)to convert from the target's frame into the ancestor's frame, as shown in events. the ancestor passed toPositionInmust itself be positioned (Relative, Absolute, or Fixed).- nullable fields as on/off toggles are the simplest state machine in goo:
nullmeans absent, a value means present. because these fields only ever change inside an event handler, plain fields are correct here; goo requests a rebuild automatically once the handler returns, so no manualRebuild()call is needed. SwallowClickstops a click from bubbling past the blob that sets it, which is why a click inside the menu does not also fire the root'sOnClickand reopen the menu at the click position;Hud.DismissScrimbundles that same swallow behavior into a ready-made click-catcher, andKeygives the menu a stable identity across rebuilds instead of matching by list position.Each.Ofdrops a data-driven loop straight into aChildreninitializer alongside literal children, auto-keying each result the same wayChildren.AddRangewould on a container built separately.
see also¶
- your first counter - the field-mutation state loop this guide builds on.
- managing state - fields,
State<T>, and cells that survive parent rebuilds. - events - the full
MousePanelEventpayload,PositionIn,SwallowClick, and the pointer-events auto-gate. - dynamic children -
Each.OfandChildren.AddRange, plus the keyed form for lists that reorder. - overlay layout -
Hud.Overlay(),Hud.Scrim(), and the anchor helpers that package the patterns used here. - build method - how a rebuild schedules a fresh
Build(), structural diff, andKeyidentity. - container reference - full style surface for
Container, includingPosition,Left,Top, andPointerEvents.