# Gum Visual Events

> Gum Visual Event Dispatch

- Skill: `vchelaru/gum-visual-events` (Agent Skill)
- Install (CLI): `npx skillmds@latest add vchelaru/gum-visual-events`
- Raw SKILL.md: https://api.skillmd.com/api/skills/vchelaru/gum-visual-events/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: vchelaru (https://skillmd.com/u/vchelaru)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/vchelaru/gum-visual-events

---


# 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](../../../docs/code/events-and-interactivity/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`.

