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)