ahel is live on Product Hunt today. Upvote

Gum Visual Event Dispatch

SkillDev tools

Gum runtime cursor-event dispatch, how Click/Push/RollOver/etc. are raised and routed. Triggers: InteractiveGue, DoUiActivityRecursively, RoutedEventArgs, HandledActions, ClickPreview, RollOverBubbling, ClickBubbling, HasEvents, ExposeChildrenEvents, adding/bubbling a visual event.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Gum Visual Event Dispatch skill

What this skill tells your AI

The instructions your AI receives, as published by vchelaru/gum in .claude/skills/gum-visual-events/SKILL.md and read by ahel’s review.

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

FileRole
GumRuntime/InteractiveGue.csAll 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:

DisciplineOrderSuppressionEvents
Tunnelingparent → child (on the way down)child skipped if a parent set HandledClickPreview, PushPreview
Bubblingdeepest child → parent (on the way up)parent skipped if a descendant set HandledRollOverBubbling, MouseWheelScroll, ClickBubbling
Single-targetfires on exactly one elementn/a — no routingClick, 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.

Signals

GitHub stars
620
Forks
80
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
gum-visual-events
Source
github.com/vchelaru/gum