Source code for simvx.core.ui.ui_input
"""UIInputManager: UI input routing, focus, modal stack, hit-testing.
All UI input flows through one contract: ``Control._on_gui_input(event)``.
When a modal Control is active (pushed via ``Control.show_modal()``), routing
is gated to the modal's subtree: outside clicks fall through to the
auto-injected ``ModalBackdrop``, keyboard events go to the focused descendant
with router-level fallbacks for Tab cycling and Escape-dismiss.
"""
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, modal stack."""
def __init__(self):
self._focused_control = None
self._mouse_grab: Any = None
self._last_mouse_pos = Vec2()
self._modal_stack: list = []
self._shortcut_handler: Any = None
self._touch_grabs: dict[int, Any] = {} # finger_id → Control for multi-touch
[docs]
def reset(self):
"""Reset transient state (called on scene change)."""
self._mouse_grab = None
self._touch_grabs.clear()
# ------------------------------------------------------------------ modal stack
[docs]
def push_modal(self, control):
"""Register a control as an active modal (captures all UI input, drawn on top).
Clears any pre-existing mouse grab outside the new modal subtree, since
modal capture invalidates grabs from the underlying scene.
"""
if control not in self._modal_stack:
self._modal_stack.append(control)
if self._mouse_grab is not None and not self._is_descendant_of(self._mouse_grab, control):
self._mouse_grab = None
[docs]
def pop_modal(self, control):
"""Unregister a modal control. Clears mouse grab and focus inside its subtree."""
if control in self._modal_stack:
self._modal_stack.remove(control)
if self._mouse_grab is not None and self._is_descendant_of(self._mouse_grab, control):
self._mouse_grab = None
if self._focused_control is not None and self._is_descendant_of(self._focused_control, control):
self._focused_control = 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
def _topmost_modal(self):
"""Return the topmost modal Control, or None if the stack is empty."""
return self._modal_stack[-1] if self._modal_stack else None
# ------------------------------------------------------------------ entry point
[docs]
def ui_input(self, root: Node | None, mouse_pos=None, button: MouseButton | None = None,
pressed: bool = True, key: str = "", char: str = ""):
"""Route a UI input event.
``button`` is a ``MouseButton`` enum for mouse press/release events,
or ``None`` for keyboard / char / pure mouse-move events.
"""
if not root:
return
from ..input.state import Input
_get_control()
UIInputEvent = _get_ui_input_event()
if mouse_pos is not None:
self._last_mouse_pos = Vec2(mouse_pos[0], mouse_pos[1])
combo_key = key
if key and key not in ("ctrl", "shift", "alt", "scroll_up", "scroll_down"):
mods = Input._keys
parts: list[str] = []
if mods.get("ctrl"):
parts.append("ctrl")
if mods.get("shift"):
parts.append("shift")
if mods.get("alt"):
parts.append("alt")
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,
)
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 controls under cursor."""
modal_root = self._topmost_modal()
if modal_root is not None:
self._dispatch_mouse_in_subtree(modal_root, 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:
target_control._internal_gui_input(event)
_debug_log_event("mouse_press" if event.pressed else "mouse_release", event, target_control, "delivered")
if event.button == MouseButton.LEFT and event.pressed and not event.handled:
self._set_focused_control(target_control)
else:
_debug_log_event("mouse_press" if event.pressed else "mouse_release", event, target_control, "miss")
def _dispatch_mouse_in_subtree(self, subtree_root, event):
"""Hit-test inside a subtree (a modal) and deliver to ``_on_gui_input``.
A modal's input scope is the whole screen even when its visible rect is
smaller: clicks outside the modal's widgets emit ``cancel_requested``
and close the modal when ``dismiss_on_outside_click`` is True.
"""
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:
target._internal_gui_input(event)
_debug_log_event(
"mouse_press" if event.pressed else "mouse_release",
event, target, "delivered",
)
if event.button == MouseButton.LEFT and event.pressed and not event.handled:
self._set_focused_control(target)
return
_debug_log_event(
"mouse_press" if event.pressed else "mouse_release",
event, target, "miss",
)
# Click landed outside every widget in the modal subtree: treat as
# outside-click dismissal.
if (event.button == MouseButton.LEFT and event.pressed
and getattr(subtree_root, "dismiss_on_outside_click", True)):
subtree_root.cancel_requested()
if hasattr(subtree_root, "close_modal"):
subtree_root.close_modal()
# ------------------------------------------------------------------ mouse move
def _handle_mouse_move(self, root: Node, event):
"""Route mouse-move events for hover tracking."""
if self._mouse_grab is not None:
self._mouse_grab._internal_gui_input(event)
return
modal_root = self._topmost_modal()
scope_root = modal_root if modal_root is not None else 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.
Bubbles up through parents until a scrollable control consumes the
event. Scrollable controls override ``_on_gui_input`` to handle
``scroll_up`` / ``scroll_down``. Modal-active: the bubble is gated to
the modal subtree.
"""
Control = _get_control()
modal_root = self._topmost_modal()
scope_root = modal_root if modal_root is not None else root
target = self._find_control_at_point(scope_root, event.position)
node = target
while node is not None:
if isinstance(node, Control) and node.mouse_filter:
if hasattr(type(node), '_on_gui_input') and type(node)._on_gui_input is not Control._on_gui_input:
node._internal_gui_input(event)
if event.handled:
return
return
if node is scope_root:
break
node = node.parent
if self._focused_control:
self._focused_control._internal_gui_input(event)
# ------------------------------------------------------------------ keyboard
def _handle_keyboard_event(self, root: Node, event):
"""Route keyboard / character events.
Modal active: focused descendant first, then router-level fallbacks
(Tab / Shift+Tab cycle focus inside the modal subtree, Escape emits
``cancel_requested`` and closes the modal when
``dismiss_on_outside_click``). Global ``_shortcut_handler`` is bypassed:
modal controls own the keyboard.
No modal: ``_shortcut_handler`` runs first, then the focused control.
"""
modal_root = self._topmost_modal()
if modal_root is None:
if self._shortcut_handler and event.pressed and event.key:
if self._shortcut_handler(event.key):
return
if self._focused_control:
self._focused_control._internal_gui_input(event)
return
# Modal active. Focused descendant gets first crack.
if self._focused_control is not None and self._is_descendant_of(self._focused_control, modal_root):
self._focused_control._internal_gui_input(event)
if event.handled:
return
else:
# Deliver to the modal root itself so it can act as a shortcut sink.
modal_root._internal_gui_input(event)
if event.handled:
return
# Router-level fallbacks. Only on key-press to avoid double-firing on release.
if event.pressed and event.key in ("tab", "shift+tab"):
self._cycle_focus(modal_root, reverse=event.key == "shift+tab")
return
if event.pressed and event.key == "escape" and getattr(modal_root, "dismiss_on_outside_click", True):
modal_root.cancel_requested()
if hasattr(modal_root, "close_modal"):
modal_root.close_modal()
def _cycle_focus(self, scope_root, reverse: bool = False) -> None:
"""Cycle keyboard focus among focusable controls inside ``scope_root``."""
Control = _get_control()
from .enums import FocusMode
focusables: list = []
Control._walk_focusable(scope_root, focusables)
focusables = [c for c in focusables if c.focus_mode == FocusMode.ALL and c.visible]
if not focusables:
return
if reverse:
focusables.reverse()
current = self._focused_control
if current in focusables:
idx = focusables.index(current)
target = focusables[(idx + 1) % len(focusables)]
else:
target = focusables[0]
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 a modal is active, ``root`` is the modal subtree 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)
modal_root = self._topmost_modal()
scope_root = modal_root if modal_root is not None else 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)
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)
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)
target._internal_gui_input(event)
# ------------------------------------------------------------------ focus
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)