Gum Visual Event Dispatch
Internals of how cursor events are raised on visuals. For the user-facing event list and "what fires when" tables, see visual-events.md — don't duplicate it here.
Where it lives
| File |
Role |
GumRuntime/InteractiveGue.cs |
All of it: event declarations, RoutedEventArgs/InputEventArgs, the HandledActions class, and DoUiActivityRecursively (the dispatch engine). |
Only types deriving from InteractiveGue raise events, and only when HasEvents == true. Forms controls layer their own events on top of these visual events (out of scope here).
The one method that matters: DoUiActivityRecursively
A single recursive walk over the visual tree drives every cursor event. It descends from the root, recurses into the deepest child under the cursor first, then unwinds. Understanding the descend-then-unwind shape is the whole game.
Three dispatch disciplines coexist (this is the non-obvious part)
The event names half-hide that three different routing models live side by side:
| Discipline |
Order |
Suppression |
Events |
| Tunneling |
parent → child (on the way down) |
child skipped if a parent set Handled |
ClickPreview, PushPreview |
| Bubbling |
deepest child → parent (on the way up) |
parent skipped if a descendant set Handled |
RollOverBubbling, MouseWheelScroll, ClickBubbling |
| Single-target |
fires on exactly one element |
n/a — no routing |
Click, Push, DoubleClick, RightClick, and most others |
These mirror WPF's Preview (tunneling) / bubbling routed-event split — same idea, same naming convention.
Gotchas
- Bubbling is NOT done by walking parent pointers. It's a single shared
HandledActions instance threaded through the recursion, combined with RoutedEventArgs.Handled. A handler sets args.Handled = true; the dispatcher copies that into a HandledActions flag (e.g. HandledRollOver), and ancestors check the flag before raising. That HandledActions + Handled pair is the routing machinery — reuse it to add routed events.
handledByChild is the opposite of bubbling. Once a child "handles" (returns true), the parent's entire click/push block is skipped (if (!handledByChild)). That short-circuit is why Click is single-target: a parent container does not get Click when a child button is clicked. Bubbling events live in a separate block that runs regardless of handledByChild, keyed off their own HandledActions flag — so they and the single-target events are independent channels.
- To make an event bubble without breaking existing behavior, add a parallel
*Bubbling event rather than changing the single-target one. Precedent: RollOverBubbling alongside RollOver, and ClickBubbling alongside Click. Give it its own HandledActions flag so its Handled only suppresses itself on ancestors and never touches single-target Click/Push.
- A click has a push origin; rollover doesn't. Single-target
Click only fires where cursor.WindowPushed == asInteractive — you must push and release on the same element. So a bubbling click can't copy the RollOverBubbling block (which fires on anything merely under the cursor). ClickBubbling solves this with a HandledActions.DidClickOccur flag set only when a real click resolves on its target; the bubbling pass then fires on that element and the ancestors it unwinds through.
- Participation is gated. Beyond
HasEvents, an element's involvement depends on ExposeChildrenEvents and IsEnabledRecursively.
Cursor.Activity() is edge-triggered — a second Update() call in the same frame collapses the press/release edge, so Push/Click silently never fire (hover still works, it's position-only). GetEventFailureReason (MonoGameGum/Input/CursorExtensions.cs) is a structural/snapshot check and won't catch this — suspect a double Update() call when it reports no problem but clicks still don't fire.
- Multiple roots are checked in reverse order, and the walk stops at the first one that claims
VisualOver. GueInteractiveExtensionMethods.DoUiActivityRecursively(IList<GraphicalUiElement>, ...) iterates the roots list back-to-front and breaks on the first root that sets cursor.VisualOver. A root whose top element has HasEvents = true and covers a large area (e.g. Dock.Fill) claims the cursor there unconditionally, including empty space — silently blocking every root earlier in the list, not just a same-parent sibling.
InputEventArgs.InputDevice carries the cursor, not the underlying hardware device — single-target input events (Click, Push, etc.) pass the ICursor through here.
- FRB does not share
InteractiveGue. It's absent from GumCoreShared.projitems; FlatRedBall has its own copy. Adding an event here does NOT reach FRB. If a shared Forms control (those ARE in FRB via FlatRedBall.Forms.Shared.projitems) starts subscribing to a new visual event, FRB's InteractiveGue must gain the same member or FRB breaks with CS1061.
1---2name: gum-visual-events3description: Gum Visual Event Dispatch4---56# Gum Visual Event Dispatch78Internals of how cursor events are raised on visuals. For the user-facing event list and "what fires when" tables, see [visual-events.md](../../../docs/code/events-and-interactivity/visual-events.md) — don't duplicate it here.910## Where it lives1112| File | Role |13| --- | --- |14| `GumRuntime/InteractiveGue.cs` | All of it: event declarations, `RoutedEventArgs`/`InputEventArgs`, the `HandledActions` class, and `DoUiActivityRecursively` (the dispatch engine). |1516Only types deriving from `InteractiveGue` raise events, and only when `HasEvents == true`. Forms controls layer their own events on top of these visual events (out of scope here).1718## The one method that matters: `DoUiActivityRecursively`1920A single recursive walk over the visual tree drives every cursor event. It descends from the root, recurses into the deepest child under the cursor first, then unwinds. Understanding the descend-then-unwind shape is the whole game.2122## Three dispatch disciplines coexist (this is the non-obvious part)2324The event *names* half-hide that three different routing models live side by side:2526| Discipline | Order | Suppression | Events |27| --- | --- | --- | --- |28| **Tunneling** | parent → child (on the way *down*) | child skipped if a parent set `Handled` | `ClickPreview`, `PushPreview` |29| **Bubbling** | deepest child → parent (on the way *up*) | parent skipped if a descendant set `Handled` | `RollOverBubbling`, `MouseWheelScroll`, `ClickBubbling` |30| **Single-target** | fires on exactly one element | n/a — no routing | `Click`, `Push`, `DoubleClick`, `RightClick`, and most others |3132These mirror WPF's Preview (tunneling) / bubbling routed-event split — same idea, same naming convention.3334## Gotchas3536- **Bubbling is NOT done by walking parent pointers.** It's a single shared `HandledActions` instance threaded through the recursion, combined with `RoutedEventArgs.Handled`. A handler sets `args.Handled = true`; the dispatcher copies that into a `HandledActions` flag (e.g. `HandledRollOver`), and ancestors check the flag before raising. That `HandledActions` + `Handled` pair *is* the routing machinery — reuse it to add routed events.37- **`handledByChild` is the opposite of bubbling.** Once a child "handles" (returns true), the parent's entire click/push block is skipped (`if (!handledByChild)`). That short-circuit is why `Click` is single-target: a parent container does **not** get `Click` when a child button is clicked. Bubbling events live in a separate block that runs regardless of `handledByChild`, keyed off their own `HandledActions` flag — so they and the single-target events are independent channels.38- **To make an event bubble without breaking existing behavior, add a parallel `*Bubbling` event** rather than changing the single-target one. Precedent: `RollOverBubbling` alongside `RollOver`, and `ClickBubbling` alongside `Click`. Give it its **own** `HandledActions` flag so its `Handled` only suppresses itself on ancestors and never touches single-target `Click`/`Push`.39- **A click has a push origin; rollover doesn't.** Single-target `Click` only fires where `cursor.WindowPushed == asInteractive` — you must push *and* release on the same element. So a bubbling click can't copy the `RollOverBubbling` block (which fires on anything merely under the cursor). `ClickBubbling` solves this with a `HandledActions.DidClickOccur` flag set only when a real click resolves on its target; the bubbling pass then fires on that element and the ancestors it unwinds through.40- **Participation is gated.** Beyond `HasEvents`, an element's involvement depends on `ExposeChildrenEvents` and `IsEnabledRecursively`.41- **`Cursor.Activity()` is edge-triggered — a second `Update()` call in the same frame collapses the press/release edge**, so Push/Click silently never fire (hover still works, it's position-only). `GetEventFailureReason` (`MonoGameGum/Input/CursorExtensions.cs`) is a structural/snapshot check and won't catch this — suspect a double `Update()` call when it reports no problem but clicks still don't fire.42- **Multiple roots are checked in reverse order, and the walk stops at the first one that claims `VisualOver`.** `GueInteractiveExtensionMethods.DoUiActivityRecursively(IList<GraphicalUiElement>, ...)` iterates the roots list back-to-front and breaks on the first root that sets `cursor.VisualOver`. A root whose top element has `HasEvents = true` and covers a large area (e.g. `Dock.Fill`) claims the cursor there unconditionally, including empty space — silently blocking every root earlier in the list, not just a same-parent sibling.43- **`InputEventArgs.InputDevice` carries the cursor**, not the underlying hardware device — single-target input events (`Click`, `Push`, etc.) pass the `ICursor` through here.44- **FRB does not share `InteractiveGue`.** It's absent from `GumCoreShared.projitems`; FlatRedBall has its own copy. Adding an event here does NOT reach FRB. If a *shared* Forms control (those ARE in FRB via `FlatRedBall.Forms.Shared.projitems`) starts subscribing to a new visual event, FRB's `InteractiveGue` must gain the same member or FRB breaks with `CS1061`.