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)