Source code for simvx.core.ui_input
"""UIInputManager — Handles UI input routing, focus, popups, and hit-testing.
Extracted from SceneTree to separate game-logic concerns (process/draw/groups)
from UI input routing (mouse events, keyboard focus, popups, hover tracking).
"""
from __future__ import annotations
import logging
from typing import Any
from .helpers import _debug_log_event, _debug_log_focus, _debug_log_hit
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 .ui import Control
_ui_Control = Control
return _ui_Control
def _get_ui_input_event() -> type:
global _ui_UIInputEvent
if _ui_UIInputEvent is None:
from .ui import UIInputEvent
_ui_UIInputEvent = UIInputEvent
return _ui_UIInputEvent
[docs]
class UIInputManager:
"""Routes UI input events to controls — focus, hover, mouse grab, popups."""
def __init__(self):
self._focused_control = None
self._mouse_grab: Any = None
self._last_mouse_pos = Vec2()
self._popup_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()
[docs]
def push_popup(self, control):
"""Register a control as an active popup (drawn on top, receives input first)."""
if control not in self._popup_stack:
self._popup_stack.append(control)
[docs]
def pop_popup(self, control):
"""Unregister a popup control."""
if control in self._popup_stack:
self._popup_stack.remove(control)
[docs]
def ui_input(self, root: Node | None, mouse_pos=None, button: int = 0,
pressed: bool = True, key: str = "", char: str = ""):
"""Route UI input events to controls."""
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)
event = UIInputEvent(
position=self._last_mouse_pos,
button=button,
pressed=pressed,
key=combo_key,
char=char,
)
if button > 0:
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)
def _handle_mouse_event(self, root: Node, event):
"""Route mouse events to controls under cursor."""
if self._popup_stack:
if event.button == 1 and event.pressed:
for popup in reversed(self._popup_stack):
if popup.is_popup_point_inside(event.position):
popup.popup_input(event)
return
from .ui.menu import MenuBar
has_menubar_popup = any(
isinstance(getattr(p, 'parent', None), MenuBar)
for p in self._popup_stack
)
for popup in list(self._popup_stack):
popup.dismiss_popup()
if not has_menubar_popup:
return
else:
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 == 1 and event.pressed:
self._set_focused_control(target_control)
else:
_debug_log_event("mouse_press" if event.pressed else "mouse_release", event, target_control, "miss")
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
self._update_mouse_over_states(root, event.position)
if self._popup_stack:
seen_parents = set()
for popup in reversed(self._popup_stack):
if hasattr(popup, '_on_gui_input'):
popup._on_gui_input(event)
parent = getattr(popup, 'parent', None)
if parent is not None and id(parent) not in seen_parents:
from .ui.menu import MenuBar
if isinstance(parent, MenuBar):
seen_parents.add(id(parent))
parent._on_gui_input(event)
return
target_control = self._find_control_at_point(root, event.position)
if target_control and target_control.mouse_filter:
target_control._internal_gui_input(event)
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 handles it.
Scrollable controls: those with a ``_on_gui_input`` that handles
scroll_up/scroll_down (TreeView, ScrollContainer, VirtualScrollContainer, etc).
"""
Control = _get_control()
target = self._find_control_at_point(root, event.position)
# Walk up from target to find a scrollable ancestor
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)
return
node = node.parent
# Fallback to focused control
if self._focused_control:
self._focused_control._internal_gui_input(event)
def _handle_keyboard_event(self, root: Node, event):
"""Route keyboard events to focused control or active popup."""
if self._shortcut_handler and event.pressed and event.key:
if self._shortcut_handler(event.key):
return
if self._popup_stack:
popup = self._popup_stack[-1]
if hasattr(popup, '_on_gui_input'):
popup._on_gui_input(event)
return
if self._focused_control:
self._focused_control._internal_gui_input(event)
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
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."""
Control = _get_control()
if self._popup_stack:
from .ui.menu import MenuBar
popup_menubars: set[int] = set()
for popup in self._popup_stack:
parent = getattr(popup, 'parent', None)
if parent is not None and isinstance(parent, MenuBar):
popup_menubars.add(id(parent))
def clear_recursive(node):
if not node.visible:
return
if isinstance(node, Control):
if id(node) in popup_menubars:
node._update_mouse_over(mouse_pos)
elif node.mouse_over:
node.mouse_over = False
node.queue_redraw()
node.mouse_exited()
for child in node.children:
clear_recursive(child)
if root:
clear_recursive(root)
return
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)
[docs]
def touch_input(self, root: Node | None, finger_id: int, action: int, x: float, y: float):
"""Route a multi-touch event to a specific control.
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.
Args:
root: Scene root node.
finger_id: Touch finger identifier.
action: 0=down, 1=up, 2=move.
x, y: Touch position in screen coordinates.
"""
if not root:
return
_get_control()
UIInputEvent = _get_ui_input_event()
pos = Vec2(x, y)
if action == 0: # down
target = self._find_control_at_point(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=1, 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=1, 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=0, pressed=False)
target._internal_gui_input(event)
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 # property setter handles queue_redraw
old.focus_exited.emit()
self._focused_control = control
if control:
control.focused = True # property setter handles queue_redraw
control.focus_entered.emit()
_debug_log_focus(old, control)