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)``.
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 Tab cycling and Escape-dismiss. A ``capture="none"`` overlay never
scopes input, so the world keeps full input beneath it.
"""

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 # 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()
[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
[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.""" 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: 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 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: 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 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 every contiguous overlay sharing that owner (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): """Route mouse-move events for hover tracking.""" 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. 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 active overlay chain. """ Control = _get_control() scope_root = self._scope_root(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. A capturing overlay scopes the keyboard to its chain (focused descendant, Tab cycle, Escape 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. """ overlay_scope = self._overlay_scope() if overlay_scope is not None: self._handle_keyboard_in_scope(overlay_scope, event) return 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) def _handle_keyboard_in_scope(self, scope_root, event): """Deliver a keyboard event scoped to the active overlay chain. Focused descendant gets first crack, then router-level fallbacks (Tab / Shift+Tab cycle, Escape dismisses the whole chain via the registry). """ # 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 event.pressed and event.key in ("tab", "shift+tab"): self._cycle_focus(scope_root, reverse=event.key == "shift+tab") return if event.pressed and event.key == "escape": self._dismiss_overlay_chain(scope_root) 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 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) 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) 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
[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)