events

every goo blob type exposes eight mouse-event init properties: OnClick, OnRightClick, OnMiddleClick, OnMouseEnter, OnMouseLeave, OnMouseDown, OnMouseUp, and OnMouseMove. all ten blob types share them: Container, Text, Image, Sector, Arc, Polygon, ScenePanel, SvgPanel, WebPanel, and TextEntry. each property accepts an Action<MousePanelEvent>. Container alone adds a ninth, OnMouseWheel, typed Action<Vector2>.

the payload

MousePanelEvent is the engine type Sandbox.UI.MousePanelEvent. the fields you reach for most:

field type what it is
LocalPosition Vector2 cursor position relative to Target's top-left, in rendered pixels.
MouseButton MouseButtons enum value for which button triggered the event (left, right, middle).
Target Panel the originating engine panel (the one that actually received the event).
This Panel the panel the event currently sits on during bubble propagation. equals Target until the event bubbles.

keyboard modifier state is not on the event, so read it from Input if you need it.

open a modal at the cursor

the common pattern (open an absolutely-positioned thing where the user clicked) hits one gotcha: LocalPosition is relative to Target, but Position = PositionMode.Absolute children are positioned relative to their nearest positioned ancestor. without a conversion, the modal lands offset by the trigger's position inside its parent.

the fix is e.PositionIn(ancestor), which translates the cursor position from the event's Target frame into the frame of the ancestor you pass in. the ancestor needs Position = PositionMode.Relative (or Absolute or Fixed) so it actually anchors absolute children.

the pattern below stores the click position in a field, then calls Rebuild() to ask goo to re-run Build() with the new value. Rebuild() is the standard way to update the screen when your data changes: mutate a field, then call it.

public class EventsDemo : GooPanel<Container>
{
    Vector2?      _modalAt;
    MouseButtons? _modalBtn;

    protected override Container Build()
    {
        var root = new Container
        {
            Position = PositionMode.Relative,
            FlexDirection = FlexDirection.Column,
            Width  = 400,
            Height = 300,
            Padding = 16,
            Gap = 12,
            BackgroundColor = Color.White,
            Children = { TriggerButton() },
        };

        if (_modalAt is { } pos)
            root.Children.Add(Modal(pos, _modalBtn));

        return root;
    }

    Container TriggerButton() => new Container
    {
        Key = "trigger",
        Padding = 12,
        BackgroundColor = Color.FromBytes(60, 120, 200),
        OnClick = e =>
        {
            _modalAt  = e.PositionIn(e.Target.Parent);
            _modalBtn = e.MouseButton;
            Rebuild();
        },
        Children = { new Text("open modal") { FontColor = Color.White } },
    };

    Container Modal(Vector2 pos, MouseButtons? btn) => new Container
    {
        Key = "modal",
        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,
        Children =
        {
            new Text($"opened with {btn} at ({pos.x:F0},{pos.y:F0})"),
            CloseButton(),
        },
    };

    Container CloseButton() => new Container
    {
        Padding = 6,
        BackgroundColor = Color.FromBytes(200, 60, 60),
        OnClick = _ =>
        {
            _modalAt  = null;
            _modalBtn = null;
            Rebuild();
        },
        Children = { new Text("close") { FontColor = Color.White } },
    };
}

e.Target.Parent happens to be the positioning ancestor here because the trigger is a direct child of root. in deeper trees Parent is not the positioning ancestor, so pass an explicit reference to the panel you actually want to position against. if you pass null, the helper falls back to LocalPosition, which is almost certainly not what you want.

note: this snippet is hand-verified against the live Goo API surface (Blob.cs, MousePanelEventExtensions.cs) but has no dedicated UAT-blessed demo backing the full open-at-cursor flow shown here. verify in s&box before shipping.

rendered-frame caveat

LocalPosition is in the engine's rendered pixel frame, while CSS values you set via Left, Top, Width, etc. are interpreted in authored CSS pixels. in a default screen panel these match, and the snippet above does the right thing.

under UI scaling or WorldPanel rendering the two frames diverge: the result from PositionIn is in rendered pixels but Left/Top is interpreted as CSS pixels. the scale-invariant option is to position by ratio, dividing the PositionIn result by the ancestor's rendered size before assigning Left/Top.

pointer events and the auto-gate

you rarely need to set PointerEvents yourself. goo resolves an effective value for every panel at apply time, in three cases:

  1. any event handler present (OnClick, OnMouseEnter, OnMouseWheel, any of them) - the panel gates to PointerEvents.All. the same applies to the intrinsically interactive blobs, TextEntry and WebPanel.
  2. any state variant present (HoverBackgroundColor, ActiveFontColor, etc.) - the state controller forces PointerEvents.All, because the engine routes input to the nearest panel whose computed pointer-events is All, and without input the :hover / :active pseudo-classes never flip. this happens after your declared value is applied, so an explicit PointerEvents.None on a panel with state variants is silently overridden. if a panel must be click-through, it cannot also carry state variants; put the variant on a child.
  3. everything else (no handlers, no variants) - the panel resolves to PointerEvents.None, so inert decoration never eats clicks meant for siblings or panels below it.

an explicit PointerEvents declaration wins over cases 1 and 3, but not case 2.

declare PointerEvents explicitly only when intent differs from the policy. the common case is PointerEvents.All on a scroll viewport: OverflowY = OverflowMode.Scroll with no handler looks inert to the policy, so without the explicit All the panel is never hovered and never receives the wheel.

defensive PointerEvents = PointerEvents.None on plain decorative containers is redundant; the auto-gate already does it.

swallowing clicks inside an overlay

a dismiss-on-click scrim and a dialog on top of it create the classic bubble trap: a click inside the dialog bubbles up to the scrim and closes the overlay. an empty handler does not fix it: OnClick = e => { } runs, but never stops propagation, so the scrim still fires.

set SwallowClick = true on the content panel instead:

var scrim = Hud.Scrim(new Color(0f, 0f, 0f, 0.55f)) with
{
    PointerEvents = PointerEvents.All,
    OnClick       = e => { _open = false; Rebuild(); },
};

var dialog = new Container
{
    SwallowClick = true,   // clicks inside no longer reach the scrim
    // ... dialog content
};

a left click on the panel then calls StopPropagation() before any parent handler sees it. if the panel also has its own OnClick, both happen: propagation stops, then your handler runs. (note that combination allocates a fresh wrapper delegate each rebuild, so it re-applies events every diff; with no OnClick the swallow delegate is shared and costs nothing.)

Popover (the built-in composed control at Controls.Popover) uses this internally on its floating content: you only need to set it yourself when hand-rolling your own overlays or dialogs.

see also