Source code for simvx.core.ui.ui_input
"""UIInputManager: UI input routing, focus, overlay scope, hit-testing.
All UI input flows through one contract: ``Control._on_gui_input(event)``.
The mouse events that bubble -- a press, a release and the wheel -- are offered
to the control under the cursor and then to each of its ancestors until one
claims the event by setting ``event.handled``; all three take that one path
(``_bubble``).
Mouse motion does not bubble. It reaches the mouse grab while one is held and
otherwise the control under the cursor alone (``_handle_mouse_move``), so a
widget that tracks the pointer -- ``Slider``, ``ColourPicker`` -- reads motion
through the same ``_on_gui_input`` without an ancestor ever seeing it.
Multi-touch (``touch_input``) is the same: the control hit at finger-down keeps
that finger's moves and its release.
When a capturing overlay is open (registered via ``Control.show_overlay``),
routing is gated to that overlay's chain (``tree.overlays``): a click outside the
chain dismisses it, keyboard events go to the focused descendant with router-level
fallbacks for focus traversal and dismissal. A ``capture="none"`` overlay never
scopes input, so the world keeps full input beneath it.
Which key does what is not decided here: the router asks ``ui/navigation.py``
whether a key is bound to ``ui_focus_next``, ``ui_focus_prev``, ``ui_accept`` or
``ui_cancel``, so navigation is rebindable through ``InputMap`` like any other
action.
"""
import logging
from typing import Any
from ..debug import _debug_log_event, _debug_log_focus, _debug_log_hit
from ..input.enums import MouseButton
from ..math.types import Vec2
from ..node import Node
log = logging.getLogger(__name__)
# Lazy-cached references for circular imports
_ui_Control: type | None = None
_ui_UIInputEvent: type | None = None
def _get_control() -> type:
global _ui_Control
if _ui_Control is None:
from . import Control
_ui_Control = Control
return _ui_Control
def _get_ui_input_event() -> type:
global _ui_UIInputEvent
if _ui_UIInputEvent is None:
from . import UIInputEvent
_ui_UIInputEvent = UIInputEvent
return _ui_UIInputEvent
[docs]
class UIInputManager:
"""Routes UI input events to controls: focus, hover, mouse grab, overlay scope."""
def __init__(self):
self._focused_control = None
self._mouse_grab: Any = None
self._last_mouse_pos = Vec2()
self._shortcut_handler: Any = None
self._touch_grabs: dict[int, Any] = {} # finger_id → Control for multi-touch
self._keys_down: set[str] = set() # keys held, so auto-repeat can be told from a new press
# Back-ref to the owning SceneTree, set by SceneTree.__init__, so the
# manager can read the overlay registry for chain-aware input scope.
self._tree: Any = None
[docs]
def reset(self):
"""Reset transient state (called on scene change)."""
self._mouse_grab = None
self._touch_grabs.clear()
self._keys_down.clear()
[docs]
def notify_subtree_removed(self, control):
"""Release focus / mouse grab when a control (or its subtree) leaves the tree.
Called from ``Control._exit_tree`` so that a destroyed or reparented
focused control never lingers as the focus owner and keeps receiving
keyboard events. The overlay registry's own focus-restore path
(``OverlayLayer.close``) handles overlay teardown separately.
"""
if self._focused_control is not None and self._is_descendant_of(self._focused_control, control):
self._set_focused_control(None)
if self._mouse_grab is not None and self._is_descendant_of(self._mouse_grab, control):
self._mouse_grab = None
@staticmethod
def _is_descendant_of(node, ancestor) -> bool:
cur = node
while cur is not None:
if cur is ancestor:
return True
cur = getattr(cur, "parent", None)
return False
# ------------------------------------------------------------------ overlay scope
def _overlay_scope(self):
"""Return the active overlay's chain-owner scope root, or None.
Reads ``tree.overlays.topmost_capturing()`` (skips ``capture=none``) and
returns its chain owner (``scope_root_of``), so hit-testing covers the
whole open chain (the bar strip + open popup + submenu), not just the
topmost leaf. ``if tree.overlays`` is the O(1) empty gate (zero-cost when
no overlay is open). A non-capturing-only registry returns None, so the
world keeps full input.
"""
tree = self._tree
if tree is None or not tree.overlays:
return None
cap = tree.overlays.topmost_capturing()
if cap is None:
return None
return tree.overlays.scope_root_of(cap)
def _scope_root(self, root):
"""Resolve the active input scope: the capturing overlay's chain owner, else root."""
scope = self._overlay_scope()
return scope if scope is not None else root
# ------------------------------------------------------------------ entry point
@staticmethod
def _modifiers(
ctrl: bool | None, shift: bool | None, alt: bool | None, meta: bool | None
) -> tuple[bool, bool, bool, bool]:
"""The event's modifier flags, filling anything unstated from held keys.
Routing always runs inside the owning tree's input span, so the ambient
``Input`` here is that tree's own state rather than the process-wide
default.
"""
from ..input.router import _resolve_modifiers
from ..input.state import Input
return _resolve_modifiers(Input, ctrl, shift, alt, meta)
[docs]
def ui_input(
self,
root: Node | None,
mouse_pos=None,
button: MouseButton | None = None,
pressed: bool = True,
key: str = "",
char: str = "",
ctrl: bool | None = None,
shift: bool | None = None,
alt: bool | None = None,
meta: bool | None = None,
):
"""Route a UI input event.
``button`` is a ``MouseButton`` enum for mouse press/release events,
or ``None`` for keyboard / char / pure mouse-move events.
``ctrl`` / ``shift`` / ``alt`` / ``meta`` are the modifier state at
event time. Leave one unset and it is read from the held-key state
(:meth:`_modifiers`). They reach the control on every event kind, so a
widget implements a Shift-click by reading ``event.shift``.
"""
if not root:
return
_get_control()
UIInputEvent = _get_ui_input_event()
if mouse_pos is not None:
self._last_mouse_pos = Vec2(mouse_pos[0], mouse_pos[1])
mod_ctrl, mod_shift, mod_alt, mod_meta = self._modifiers(ctrl, shift, alt, meta)
combo_key = key
if key and key not in ("ctrl", "shift", "alt", "scroll_up", "scroll_down"):
parts = [name for name, held in (("ctrl", mod_ctrl), ("shift", mod_shift), ("alt", mod_alt)) if held]
if parts:
parts.append(key)
combo_key = "+".join(parts)
# Normalise raw ints into MouseButton enum so the rest of the routing
# layer can rely on ``event.button`` being either a MouseButton or None.
if button is not None and not isinstance(button, MouseButton):
button = MouseButton(int(button))
event = UIInputEvent(
position=self._last_mouse_pos,
button=button,
pressed=pressed,
key=combo_key,
char=char,
ctrl=mod_ctrl,
shift=mod_shift,
alt=mod_alt,
meta=mod_meta,
)
if button is not None:
self._handle_mouse_event(root, event)
elif mouse_pos is not None and not key and not char:
self._handle_mouse_move(root, event)
elif combo_key in ("scroll_up", "scroll_down"):
self._handle_scroll_event(root, event)
elif key or char:
self._handle_keyboard_event(root, event)
# ------------------------------------------------------------------ mouse press / release
def _handle_mouse_event(self, root: Node, event):
"""Route mouse press / release events to the control under the cursor, and up.
The button event bubbles exactly as the wheel does (:meth:`_bubble`): the
control under the cursor is offered it first, then each ancestor, until
one consumes it. A widget that merely draws -- a label, a plain panel --
no longer swallows a click on its way to the panel that wanted it, and a
widget that does nothing with this button hands it to the widget around
it that does.
"""
overlay_scope = self._overlay_scope()
if overlay_scope is not None:
self._dispatch_mouse_in_subtree(overlay_scope, event)
return
if self._mouse_grab is not None:
self._mouse_grab._internal_gui_input(event)
_debug_log_event("mouse_press" if event.pressed else "mouse_release", event, self._mouse_grab, "grabbed")
return
target_control = self._find_control_at_point(root, event.position)
self._update_mouse_over_states(root, event.position)
if target_control and target_control.mouse_filter:
consumer = self._bubble(target_control, event, root)
_debug_log_event("mouse_press" if event.pressed else "mouse_release", event, target_control, "delivered")
self._focus_on_press(event, target_control, consumer)
else:
_debug_log_event("mouse_press" if event.pressed else "mouse_release", event, target_control, "miss")
def _focus_on_press(self, event, target, consumer) -> None:
"""Give focus to the owner of a left press: whoever consumed it, else the target.
A click's owner is the control that acted on it. With the button event
bubbling, that is not always the control under the cursor: a click on a
label inside a panel that takes clicks belongs to the panel. When nothing
in the chain consumed the press, the control under the cursor owns it,
which is what a click on a plain widget has always meant here.
``focus_mode`` is not consulted: whatever owns the press takes focus,
including a control whose ``focus_mode`` is ``NONE``. That is how the
router has always assigned focus on a click.
"""
if event.button == MouseButton.LEFT and event.pressed:
self._set_focused_control(consumer if consumer is not None else target)
def _bubble(self, target, event, scope_root):
"""Offer *event* to *target*, then to each ancestor up to *scope_root*.
Returns the control that consumed it (the one that set ``event.handled``),
or ``None`` when the chain let it through. This is the one delivery path
for both the wheel and the mouse buttons, so "claimed" means the same
thing to every widget: set ``event.handled`` (``event.accept()``) and the
chain stops there.
Only controls that override ``_on_gui_input`` are visited: the base
implementation is a no-op, and skipping it keeps the ``gui_input`` signal
off the bubble path for controls that never look at input. Every
overriding ancestor the bubble visits receives the event through the
ordinary delivery, its ``gui_input`` signal included, until one consumes
it -- so for one event that signal can fire on more than the innermost
control.
"""
Control = _get_control()
node = target
while node is not None:
if isinstance(node, Control) and node.mouse_filter:
if type(node)._on_gui_input is not Control._on_gui_input:
node._internal_gui_input(event)
if event.handled:
return node
if node is scope_root:
break
node = node.parent
return None
def _dispatch_mouse_in_subtree(self, subtree_root, event):
"""Hit-test inside the active overlay scope and deliver to ``_on_gui_input``.
The scope is the whole screen even when the overlay's visible rect is
smaller: a click outside every widget in the scope dismisses the whole
capturing chain (via the overlay registry) when its topmost entry has
``dismiss_on_outside_click``.
"""
if self._mouse_grab is not None and self._is_descendant_of(self._mouse_grab, subtree_root):
self._mouse_grab._internal_gui_input(event)
_debug_log_event(
"mouse_press" if event.pressed else "mouse_release",
event,
self._mouse_grab,
"grabbed",
)
return
target = self._find_control_at_point(subtree_root, event.position)
self._update_mouse_over_states(subtree_root, event.position)
if target and target.mouse_filter:
consumer = self._bubble(target, event, subtree_root)
_debug_log_event(
"mouse_press" if event.pressed else "mouse_release",
event,
target,
"delivered",
)
self._focus_on_press(event, target, consumer)
return
_debug_log_event(
"mouse_press" if event.pressed else "mouse_release",
event,
target,
"miss",
)
# Click landed outside every widget in the scope subtree: dismiss the chain.
if event.button == MouseButton.LEFT and event.pressed:
self._dismiss_overlay_chain(subtree_root)
def _dismiss_overlay_chain(self, owner):
"""Dismiss the active capturing overlay chain on an outside click / Escape.
Routes to the chain owner and closes the owner's chain base and every
later entry sharing that owner, leaving interleaved entries of other
owners in place (whole-chain dismissal). Honours
``dismiss_on_outside_click`` on the topmost capturing entry.
``owner.cancel_requested`` fires so the logical ``menu.py`` unwind still
runs.
"""
tree = self._tree
if tree is None or not tree.overlays:
return
cap = tree.overlays.topmost_capturing()
if cap is None:
return
entry = tree.overlays.entry_of(cap)
if entry is not None and not entry.dismiss_on_outside_click:
return
cr = getattr(owner, "cancel_requested", None)
if cr is not None:
cr()
# Close from the chain BASE so the whole owner chain (bar + popup + submenu)
# clears in one action. chain_base is the earliest-opened entry for this
# owner; closing it pops it and every entry above sharing the owner (LIFO).
base = tree.overlays.chain_base(owner) or cap
if hasattr(base, "close_overlay"):
base.close_overlay()
else:
tree.overlays.close(base)
# ------------------------------------------------------------------ mouse move
def _handle_mouse_move(self, root: Node, event):
"""Update hover state and deliver the move to the control under the cursor.
Motion is delivered, not bubbled: the control the point lands in is the
only one offered it, so a panel does not see the moves passing over the
widgets inside it. A widget that needs the pointer past its own edge
holds the mouse grab, which takes the move ahead of any hit-test.
Hover is separate from delivery -- ``mouse_over`` is recomputed for every
control the point falls inside, ancestors included.
"""
if self._mouse_grab is not None:
self._mouse_grab._internal_gui_input(event)
return
scope_root = self._scope_root(root)
self._update_mouse_over_states(scope_root, event.position)
target = self._find_control_at_point(scope_root, event.position)
if target and target.mouse_filter:
target._internal_gui_input(event)
# ------------------------------------------------------------------ scroll
def _handle_scroll_event(self, root: Node, event):
"""Route scroll events to the control under the mouse cursor, and up.
The wheel bubbles (:meth:`_bubble`) until a control consumes it, so a
list of buttons scrolls the container it sits in, and a widget that uses
the wheel itself -- a spin box, a nested list with room to move -- keeps
it. Modal-active: the bubble is gated to the active overlay chain.
Where the wheel goes is decided by what is under the cursor and nothing
else. The focus owner has no claim on it: a focused text view does not
scroll while the cursor rests over a dialog, a panel or the window
background, which is what the pointer-driven wheel means everywhere. A
dialog that must keep the wheel inside it claims it at its own root.
"""
scope_root = self._scope_root(root)
target = self._find_control_at_point(scope_root, event.position)
if target is not None:
self._bubble(target, event, scope_root)
# ------------------------------------------------------------------ keyboard
def _handle_keyboard_event(self, root: Node, event):
"""Route keyboard / character events.
A capturing overlay scopes the keyboard to its chain (focused descendant,
traversal, dismiss). A ``capture=none`` overlay is skipped by
``topmost_capturing``, so the keyboard reaches the world beneath it.
No capturing overlay: ``_shortcut_handler`` runs first, then the focused
control, then ``ui_focus_next`` / ``ui_focus_prev`` traversal and
``ui_accept`` activation within the tree.
The focused control claims a key by setting ``event.handled``, and then
neither traversal nor activation runs: that is how ``CodeTextEdit`` keeps
Tab for indent and Enter for a newline. Both also run only when the tree
owns focus. With nothing focused the key is left alone, so a game binding
its own action to Tab or Enter keeps it.
"""
echo = self._track_key_hold(event)
overlay_scope = self._overlay_scope()
if overlay_scope is not None:
self._handle_keyboard_in_scope(overlay_scope, event, echo=echo)
return
if self._shortcut_handler and event.pressed and event.key:
if self._shortcut_handler(event.key):
return
focused = self._focused_control
if focused is None:
return
focused._internal_gui_input(event)
if event.handled or not event.pressed:
return
if self._is_descendant_of(focused, root):
if self._navigate(root, event):
return
self._activate_focus_owner(root, event, echo=echo)
def _handle_keyboard_in_scope(self, scope_root, event, *, echo: bool = False):
"""Deliver a keyboard event scoped to the active overlay chain.
Focused descendant gets first crack, then router-level fallbacks
(``ui_focus_next`` / ``ui_focus_prev`` traversal, ``ui_accept`` activates
the focus owner, ``ui_cancel`` dismisses that scope's whole chain via the
registry: the owner's chain base and every later entry sharing its owner,
leaving interleaved entries of other owners in place).
``echo`` marks an auto-repeat press, which the two act-once fallbacks
ignore -- see :meth:`_track_key_hold`.
"""
# Focused descendant gets first crack.
if self._focused_control is not None and self._is_descendant_of(self._focused_control, scope_root):
self._focused_control._internal_gui_input(event)
if event.handled:
return
else:
# Deliver to the scope root itself so it can act as a shortcut sink.
scope_root._internal_gui_input(event)
if event.handled:
return
# Router-level fallbacks. Only on key-press to avoid double-firing on release.
if not event.pressed:
return
if self._navigate(scope_root, event):
return
if self._activate_focus_owner(scope_root, event, echo=echo):
return
if not echo and self._matches("ui_cancel", event.key):
self._dismiss_overlay_chain(scope_root)
def _track_key_hold(self, event) -> bool:
"""Record which keys are down; return True when this press is an auto-repeat.
Every window backend forwards the OS auto-repeat of a held key as another
press: GLFW's and SDL3's ``REPEAT`` action and the browser's repeated
``keydown`` all arrive here as ``pressed=True``, indistinguishable from a
fresh keystroke. The router cannot see the repeat flag, but it can see
that the key never came back up, which is the same fact: a press of a key
already held is an echo.
Traversal is deliberately NOT gated on this -- a held Tab should walk the
tab order, as it does everywhere else. The act-once fallbacks are:
activation, which presses a button, and dismissal, which closes an
overlay. Repeating either turns one keystroke into a confirmed dialog or
a chain of dismissals.
Modifiers are stripped, so a release that arrives after the modifier was
let go still clears the hold. If a release is missed entirely (the window
losing focus mid-keystroke), the next press of that key is read as an
echo and skipped, and its release restores normal service.
"""
key = event.key.rsplit("+", 1)[-1]
if not key:
return False
if not event.pressed:
self._keys_down.discard(key)
return False
if key in self._keys_down:
return True
self._keys_down.add(key)
return False
def _matches(self, action: str, key: str) -> bool:
"""True when ``key`` is bound to a UI navigation action on this tree's map."""
from .navigation import ui_action_matches
tree = self._tree
return ui_action_matches(action, key, tree.input_map if tree is not None else None)
def _navigate(self, scope_root, event) -> bool:
"""Run focus traversal if the key is bound to one of the traversal actions."""
if self._matches("ui_focus_next", event.key):
self._traverse_focus(scope_root)
return True
if self._matches("ui_focus_prev", event.key):
self._traverse_focus(scope_root, reverse=True)
return True
return False
def _activate_focus_owner(self, scope_root, event, *, echo: bool = False) -> bool:
"""Activate the focus owner when the key is bound to ``ui_accept``.
Runs on the key PRESS only (both callers gate on ``event.pressed``), and
not on the OS auto-repeat of a press already seen (``echo``, from
:meth:`_track_key_hold`), so a single keystroke activates exactly once
however long it is held down before release.
The owner had first refusal: it claims the key by setting
``event.handled``, and this never runs then. So a focused ``TextEdit``
keeps Enter for submit, a ``CodeTextEdit`` keeps it for a newline, and a
focused ``Button`` -- which wants neither -- is pressed. Only the focus
owner is ever activated; there is no default-button rule, so a dialog that
wants Enter in its text field to confirm connects ``text_submitted``.
The owner must still be legitimately focusable at this instant --
:func:`~.focus.is_focusable`, the same authority traversal uses, so a
disabled or hidden owner is skipped. Neither state releases focus on its
own, so a control can go dark while still owning the key; every other
routing path in this file gates on visibility, and activation, which
acts rather than merely accepts, has more reason to than any of them.
"""
from .focus import is_focusable
if echo or not self._matches("ui_accept", event.key):
return False
focused = self._focused_control
if focused is None or not is_focusable(focused) or not self._is_descendant_of(focused, scope_root):
return False
return bool(focused.activate())
def _traverse_focus(self, scope_root, *, reverse: bool = False) -> None:
"""Move focus to the next (or previous) tab stop inside ``scope_root``.
With no focus owner in the scope the first (last, when reversing) tab
stop is elected, which is what an overlay that just opened wants.
"""
from .focus import next_focus
target = next_focus(scope_root, self._focused_control, reverse=reverse)
if target is not None:
self._set_focused_control(target)
# ------------------------------------------------------------------ hit-test
def _find_control_at_point(self, root: Node, point):
"""Find topmost control at screen position (recursive depth-first)."""
Control = _get_control()
def find_recursive(node):
if not node.visible:
return None
# Clipping containers (ScrollContainer, etc.) hide overflow
# visually; they must also hide it from hit-test so cursor events
# don't pass through to scrolled-out children.
if isinstance(node, Control) and node._clips_input and not node.is_point_inside(point):
return None
for child in reversed(list(node.children)):
result = find_recursive(child)
if result:
return result
if isinstance(node, Control) and node.mouse_filter:
if node.is_point_inside(point):
return node
return None
result = find_recursive(root) if root else None
_debug_log_hit(point, result)
return result
def _update_mouse_over_states(self, root: Node, mouse_pos):
"""Update ``mouse_over`` state for all Controls under ``root``.
When an overlay is capturing, ``root`` is the overlay scope and the update
walks just that subtree.
"""
Control = _get_control()
def update_recursive(node):
if not node.visible:
return
if isinstance(node, Control):
node._update_mouse_over(mouse_pos)
for child in node.children:
update_recursive(child)
if root:
update_recursive(root)
# ------------------------------------------------------------------ touch
[docs]
def touch_input(self, root: Node | None, finger_id: int, action: int, x: float, y: float):
"""Route a multi-touch event.
For controls with ``touch_mode='multi'``, each finger is tracked
independently. On down: hit-test for the control, store in
``_touch_grabs``, deliver press. On move: deliver to grabbed control.
On up: deliver release, remove grab.
"""
if not root:
return
_get_control()
UIInputEvent = _get_ui_input_event()
pos = Vec2(x, y)
# A touch carries no modifiers of its own; a keyboard held alongside it
# is real state and reaches the control the same way it does on a mouse.
ctrl, shift, alt, meta = self._modifiers(None, None, None, None)
mods = {"ctrl": ctrl, "shift": shift, "alt": alt, "meta": meta}
scope_root = self._scope_root(root)
if action == 0: # down
target = self._find_control_at_point(scope_root, pos)
if target and target.mouse_filter and getattr(target, "touch_mode", "mouse") == "multi":
self._touch_grabs[finger_id] = target
event = UIInputEvent(position=pos, button=MouseButton.LEFT, pressed=True, **mods)
target._internal_gui_input(event)
elif action == 1: # up
target = self._touch_grabs.pop(finger_id, None)
if target:
event = UIInputEvent(position=pos, button=MouseButton.LEFT, pressed=False, **mods)
target._internal_gui_input(event)
elif action == 2: # move
target = self._touch_grabs.get(finger_id)
if target:
event = UIInputEvent(position=pos, button=None, pressed=False, **mods)
target._internal_gui_input(event)
# ------------------------------------------------------------------ focus
[docs]
def has_pending_edit(self) -> bool:
"""True if the focused control holds an uncommitted edit (e.g. a TextEdit)."""
fc = self._focused_control
check = getattr(fc, "_has_pending_edit", None) if fc is not None else None
return bool(check()) if check is not None else False
[docs]
def commit_pending_edit(self) -> None:
"""Commit the focused control's in-progress edit now, if it has one.
Idempotent (no-op when nothing is pending). Consumers call this before
tearing down or replacing a focused editor so the typed value is not lost
when the widget is removed (e.g. the inspector rebuilding after a rename).
"""
fc = self._focused_control
commit = getattr(fc, "_commit", None) if fc is not None else None
if commit is not None:
commit()
def _set_focused_control(self, control):
"""Set the focused control, removing focus from previous."""
if self._focused_control is control:
return
old = self._focused_control
if old:
old.focused = False
old.focus_exited.emit()
self._focused_control = control
if control:
control.focused = True
control.focus_entered.emit()
_debug_log_focus(old, control)