-
Notifications
You must be signed in to change notification settings - Fork 0
Event Handling
GestureDetector is the primary widget for responding to pointer (mouse) events. It wraps a
child and fires user callbacks when events occur:
new GestureDetector(
onTap: e => Console.WriteLine("clicked"),
onEnter: e => SetState(() => _hovered = true),
onExit: e => SetState(() => _hovered = false),
onPress: e => Console.WriteLine("mouse down"),
onRelease: e => Console.WriteLine("mouse up"),
onMove: e => Console.WriteLine($"moved to {e.X}, {e.Y}"),
onWheel: e => Console.WriteLine($"scroll delta: {e.Delta}"),
child: new Container(
style: new BoxStyle
{
Color = _hovered
? new Vector4(0.4f, 0.6f, 1, 1)
: new Vector4(0.2f, 0.3f, 0.6f, 1)
},
child: new Text("Hover me")
)
)All callbacks are optional — pass only the ones you need. When a callback fires, the event's
Handled flag is set to true, preventing it from being received by elements underneath.
| Property | Type | Description |
|---|---|---|
X |
float |
Local X coordinate (relative to this element's top-left) |
Y |
float |
Local Y coordinate |
Delta |
float |
Scroll wheel delta (positive = scroll down) |
Button |
PointerButton |
Left, Right, Middle, or None
|
Handled |
bool |
Set to true to stop event propagation |
MouseRegion detects pointer enter/exit/hover events and optionally changes the mouse
cursor shape while the pointer is over it. Unlike GestureDetector, it does not handle
clicks, presses, or scroll — it is purpose-built for hover tracking and cursor management.
new MouseRegion(
cursor: MouseCursor.LinkSelect,
onEnter: e => SetState(() => _hovered = true),
onExit: e => SetState(() => _hovered = false),
child: new Text("Link-style text")
)Uses Vintage Story's string-based cursor system (GuiDialog.MouseOverCursor).
| Cursor | VS cursor name | Description |
|---|---|---|
MouseCursor.LinkSelect |
"linkselect" |
Link/button select cursor |
MouseCursor.TextSelect |
"textselect" |
Text selection cursor |
MouseCursor.Custom("name") |
any string | Custom VS cursor name |
When MouseRegion widgets are nested, the innermost region's cursor wins. A region
with cursor: null inherits from the nearest ancestor that specifies one (or defaults to
the standard arrow).
The event system performs a depth-first hit-test of the element tree when a pointer event arrives. Children are tested in reverse order (top-most visually = last in list = tested first).
-
GuiBasereceives the OS event and converts screen coordinates to window-local coordinates (accounting forWindowPosand UI scale) - Drag/resize interactions are handled first — if the pointer is on a resize edge or drag handle, the event is consumed by the window system
-
EventDispatcher.DispatchPointerDown(root, e)walks the element tree -
Element.HitTest(result, position)recursively tests whether the pointer falls within each element's bounds, returning the deepest hit -
FindTarget(result)scans the hit path for the first element that implements a pointer interface - The event is dispatched to the target and bubbles upward through ancestors
When a PointerDown event is handled, the target element is captured. All subsequent
PointerMove events are routed directly to the captured element, even if the pointer moves
outside its bounds. The capture is released on PointerUp. This is what makes drag-and-drop
and slider-drag feel correct.
A click (onTap) fires on PointerUp only if the hit-test at mouse-up resolves to the
same element that received PointerDown. Moving the mouse off an element before releasing
does not fire onTap.
You can implement pointer interfaces directly on a StatefulWidget's State class:
class _MyInteractiveState : State<_MyWidget>,
IPointerDownHandler, IPointerClickHandler, IPointerEnterHandler, IPointerExitHandler
{
bool _pressed = false;
public void OnPointerDown(PointerEvent e)
{
SetState(() => _pressed = true);
e.Handled = true;
}
public void OnPointerClick(PointerEvent e)
{
Console.WriteLine("clicked!");
e.Handled = true;
}
public void OnPointerEnter(PointerEvent e) { /* hover start */ }
public void OnPointerExit(PointerEvent e) { /* hover end */ }
public override Widget Build(BuildContext ctx) =>
new Text(_pressed ? "PRESSED" : "Click me");
}The dispatcher checks both element.Widget and element.State for interface implementations,
so you can place handlers on either.
Keyboard events are routed through the focus system. Only the focused element (and its ancestors) receives keyboard events.
class _TextWidgetState : State<_TextWidget>, IFocusable,
IKeyDownHandler, IKeyCharHandler
{
public FocusNode FocusNode { get; } = new();
public void OnKeyDown(KeyboardEvent e)
{
if (e.KeyCode == (int)GlKeys.Escape)
{
Element.Owner.FocusManager.RequestFocus(null); // clear focus
e.Handled = true;
}
}
public void OnKeyChar(KeyboardEvent e)
{
SetState(() => _text += e.KeyChar);
e.Handled = true;
}
public override Widget Build(BuildContext ctx)
{
bool focused = FocusNode.HasFocus;
return new GestureDetector(
onTap: _ => Element.Owner.FocusManager.RequestFocus(FocusNode),
child: new Container(
style: new BoxStyle
{
BorderThickness = focused ? 2 : 1,
BorderColor = focused
? new Vector4(0.4f, 0.7f, 1, 1)
: new Vector4(1, 1, 1, 0.3f)
},
child: new Text(_text)
)
);
}
}FocusNode extends ChangeNotifier. Widgets can listen to FocusNode.AddListener(...) to
react when focus is gained or lost.
| Property | Type | Description |
|---|---|---|
Type |
KeyEventType |
KeyDown, KeyUp, or KeyChar
|
KeyCode |
int |
Raw key code (compare with (int)GlKeys.Enter etc.) |
KeyChar |
char |
Character for KeyChar events |
Shift |
bool |
Shift modifier held |
Ctrl |
bool |
Ctrl modifier held |
Alt |
bool |
Alt modifier held |
Handled |
bool |
Set to stop propagation |
| Interface | Method | When called |
|---|---|---|
IKeyDownHandler |
OnKeyDown(KeyboardEvent) |
Key pressed (repeats while held) |
IKeyUpHandler |
OnKeyUp(KeyboardEvent) |
Key released |
IKeyCharHandler |
OnKeyChar(KeyboardEvent) |
Printable character typed |
When RequestFocus(node) is called:
- The old focused element's
FocusNode.SetHasFocus(false)is called, notifying listeners - The new focused element's
FocusNode.SetHasFocus(true)is called -
FocusManager.PrimaryFocusis updated to the new node
On the next keyboard event, EventDispatcher reads PrimaryFocus.Owner to find the focused
element, then dispatches up the ancestor chain.
Clicking empty space (where FindTarget finds no interactive element) automatically clears
focus by calling RequestFocus(null).
The onWheel callback on GestureDetector receives the wheel delta. Positive delta = scroll
down. The ListView and SingleChildScrollView handle wheel events internally; use onWheel
for custom scroll behavior:
new GestureDetector(
onWheel: e =>
{
SetState(() => _zoom += e.Delta * 0.1f);
e.Handled = true;
},
child: new ZoomableView(zoom: _zoom)
)The Vintage Story GuiManager dispatches OnMouseWheel in three consecutive loops:
-
CaptureAllInputs loop — only dialogs returning
truefromCaptureAllInputs(). -
Bounds loop (priority) — dialogs whose
Composersdictionary contains at least one composer whoseBounds.PointInside(mouseX, mouseY)is true. If a dialog is hit here, itsOnMouseWheelis called before other low-priority dialogs. -
Catch-all loop — every open dialog with
ShouldReceiveMouseEvents() == true, in registration order. Once any dialog setsargs.IsHandled, no subsequent dialog receives the event.
GuiBase does not use VS GuiComposer at all — Composers is always empty. This means it
is skipped by loop 2 and falls into loop 3, where another open dialog may intercept the
wheel event first.
Fix: GuiBase.SyncHitBounds() registers a zero-element GuiComposer whose Bounds
match the current window rect. This makes the engine find the dialog in loop 2 (the priority
path) so OnMouseWheel is delivered with higher priority. SyncHitBounds is called on open,
on drag, and on resize to keep the bounds in sync.
Note: This is an implementation detail of
GuiBaseand is fully automatic. You do not need to callSyncHitBoundsyourself. It only matters if you are debugging why wheel events are not arriving at a customGuiBasesubclass — check that the dialog'sWindowPosandWindowSizeare set correctly at open time.
BoxStyle.HitTestBehavior controls how a container participates in hit-testing:
| Value | Behavior |
|---|---|
Defer |
Default: the container is a hit target only if it has visible content (color, border, or texture) |
Opaque |
Always a hit target, regardless of visual content |
Translucent |
Never a hit target; pointer events pass through to elements behind it |
// Invisible overlay that still catches all clicks:
new Container(
style: new BoxStyle { HitTestBehavior = HitTestBehavior.Opaque }
)
// Transparent container that doesn't block clicks:
new Container(
style: new BoxStyle { HitTestBehavior = HitTestBehavior.Translucent },
child: new Text("Clickable text underneath")
)