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)``.
The mouse events that bubble -- a press, a release and the wheel -- are offered
to the control under the cursor and then to each of its ancestors until one
claims the event by setting ``event.handled``; all three take that one path
(``_bubble``).
Mouse motion does not bubble. It reaches the mouse grab while one is held and
otherwise the control under the cursor alone (``_handle_mouse_move``), so a
widget that tracks the pointer -- ``Slider``, ``ColourPicker`` -- reads motion
through the same ``_on_gui_input`` without an ancestor ever seeing it.
Multi-touch (``touch_input``) is the same: the control hit at finger-down keeps
that finger's moves and its release.
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 focus traversal and dismissal. A ``capture="none"`` overlay never
scopes input, so the world keeps full input beneath it.

Which key does what is not decided here: the router asks ``ui/navigation.py``
whether a key is bound to ``ui_focus_next``, ``ui_focus_prev``, ``ui_accept`` or
``ui_cancel``, so navigation is rebindable through ``InputMap`` like any other
action.
"""

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 self._keys_down: set[str] = set() # keys held, so auto-repeat can be told from a new press # 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() self._keys_down.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 @staticmethod def _modifiers( ctrl: bool | None, shift: bool | None, alt: bool | None, meta: bool | None ) -> tuple[bool, bool, bool, bool]: """The event's modifier flags, filling anything unstated from held keys. Routing always runs inside the owning tree's input span, so the ambient ``Input`` here is that tree's own state rather than the process-wide default. """ from ..input.router import _resolve_modifiers from ..input.state import Input return _resolve_modifiers(Input, ctrl, shift, alt, meta)
[docs] def ui_input( self, root: Node | None, mouse_pos=None, button: MouseButton | None = None, pressed: bool = True, key: str = "", char: str = "", ctrl: bool | None = None, shift: bool | None = None, alt: bool | None = None, meta: bool | None = None, ): """Route a UI input event. ``button`` is a ``MouseButton`` enum for mouse press/release events, or ``None`` for keyboard / char / pure mouse-move events. ``ctrl`` / ``shift`` / ``alt`` / ``meta`` are the modifier state at event time. Leave one unset and it is read from the held-key state (:meth:`_modifiers`). They reach the control on every event kind, so a widget implements a Shift-click by reading ``event.shift``. """ if not root: return _get_control() UIInputEvent = _get_ui_input_event() if mouse_pos is not None: self._last_mouse_pos = Vec2(mouse_pos[0], mouse_pos[1]) mod_ctrl, mod_shift, mod_alt, mod_meta = self._modifiers(ctrl, shift, alt, meta) combo_key = key if key and key not in ("ctrl", "shift", "alt", "scroll_up", "scroll_down"): parts = [name for name, held in (("ctrl", mod_ctrl), ("shift", mod_shift), ("alt", mod_alt)) if held] 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, ctrl=mod_ctrl, shift=mod_shift, alt=mod_alt, meta=mod_meta, ) 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 the control under the cursor, and up. The button event bubbles exactly as the wheel does (:meth:`_bubble`): the control under the cursor is offered it first, then each ancestor, until one consumes it. A widget that merely draws -- a label, a plain panel -- no longer swallows a click on its way to the panel that wanted it, and a widget that does nothing with this button hands it to the widget around it that does. """ 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: consumer = self._bubble(target_control, event, root) _debug_log_event("mouse_press" if event.pressed else "mouse_release", event, target_control, "delivered") self._focus_on_press(event, target_control, consumer) else: _debug_log_event("mouse_press" if event.pressed else "mouse_release", event, target_control, "miss") def _focus_on_press(self, event, target, consumer) -> None: """Give focus to the owner of a left press: whoever consumed it, else the target. A click's owner is the control that acted on it. With the button event bubbling, that is not always the control under the cursor: a click on a label inside a panel that takes clicks belongs to the panel. When nothing in the chain consumed the press, the control under the cursor owns it, which is what a click on a plain widget has always meant here. ``focus_mode`` is not consulted: whatever owns the press takes focus, including a control whose ``focus_mode`` is ``NONE``. That is how the router has always assigned focus on a click. """ if event.button == MouseButton.LEFT and event.pressed: self._set_focused_control(consumer if consumer is not None else target) def _bubble(self, target, event, scope_root): """Offer *event* to *target*, then to each ancestor up to *scope_root*. Returns the control that consumed it (the one that set ``event.handled``), or ``None`` when the chain let it through. This is the one delivery path for both the wheel and the mouse buttons, so "claimed" means the same thing to every widget: set ``event.handled`` (``event.accept()``) and the chain stops there. Only controls that override ``_on_gui_input`` are visited: the base implementation is a no-op, and skipping it keeps the ``gui_input`` signal off the bubble path for controls that never look at input. Every overriding ancestor the bubble visits receives the event through the ordinary delivery, its ``gui_input`` signal included, until one consumes it -- so for one event that signal can fire on more than the innermost control. """ Control = _get_control() node = target while node is not None: if isinstance(node, Control) and node.mouse_filter: if type(node)._on_gui_input is not Control._on_gui_input: node._internal_gui_input(event) if event.handled: return node if node is scope_root: break node = node.parent return None 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: consumer = self._bubble(target, event, subtree_root) _debug_log_event( "mouse_press" if event.pressed else "mouse_release", event, target, "delivered", ) self._focus_on_press(event, target, consumer) 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 the owner's chain base and every later entry sharing that owner, leaving interleaved entries of other owners in place (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): """Update hover state and deliver the move to the control under the cursor. Motion is delivered, not bubbled: the control the point lands in is the only one offered it, so a panel does not see the moves passing over the widgets inside it. A widget that needs the pointer past its own edge holds the mouse grab, which takes the move ahead of any hit-test. Hover is separate from delivery -- ``mouse_over`` is recomputed for every control the point falls inside, ancestors included. """ 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, and up. The wheel bubbles (:meth:`_bubble`) until a control consumes it, so a list of buttons scrolls the container it sits in, and a widget that uses the wheel itself -- a spin box, a nested list with room to move -- keeps it. Modal-active: the bubble is gated to the active overlay chain. Where the wheel goes is decided by what is under the cursor and nothing else. The focus owner has no claim on it: a focused text view does not scroll while the cursor rests over a dialog, a panel or the window background, which is what the pointer-driven wheel means everywhere. A dialog that must keep the wheel inside it claims it at its own root. """ scope_root = self._scope_root(root) target = self._find_control_at_point(scope_root, event.position) if target is not None: self._bubble(target, event, scope_root) # ------------------------------------------------------------------ keyboard def _handle_keyboard_event(self, root: Node, event): """Route keyboard / character events. A capturing overlay scopes the keyboard to its chain (focused descendant, traversal, 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, then ``ui_focus_next`` / ``ui_focus_prev`` traversal and ``ui_accept`` activation within the tree. The focused control claims a key by setting ``event.handled``, and then neither traversal nor activation runs: that is how ``CodeTextEdit`` keeps Tab for indent and Enter for a newline. Both also run only when the tree owns focus. With nothing focused the key is left alone, so a game binding its own action to Tab or Enter keeps it. """ echo = self._track_key_hold(event) overlay_scope = self._overlay_scope() if overlay_scope is not None: self._handle_keyboard_in_scope(overlay_scope, event, echo=echo) return if self._shortcut_handler and event.pressed and event.key: if self._shortcut_handler(event.key): return focused = self._focused_control if focused is None: return focused._internal_gui_input(event) if event.handled or not event.pressed: return if self._is_descendant_of(focused, root): if self._navigate(root, event): return self._activate_focus_owner(root, event, echo=echo) def _handle_keyboard_in_scope(self, scope_root, event, *, echo: bool = False): """Deliver a keyboard event scoped to the active overlay chain. Focused descendant gets first crack, then router-level fallbacks (``ui_focus_next`` / ``ui_focus_prev`` traversal, ``ui_accept`` activates the focus owner, ``ui_cancel`` dismisses that scope's whole chain via the registry: the owner's chain base and every later entry sharing its owner, leaving interleaved entries of other owners in place). ``echo`` marks an auto-repeat press, which the two act-once fallbacks ignore -- see :meth:`_track_key_hold`. """ # 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 not event.pressed: return if self._navigate(scope_root, event): return if self._activate_focus_owner(scope_root, event, echo=echo): return if not echo and self._matches("ui_cancel", event.key): self._dismiss_overlay_chain(scope_root) def _track_key_hold(self, event) -> bool: """Record which keys are down; return True when this press is an auto-repeat. Every window backend forwards the OS auto-repeat of a held key as another press: GLFW's and SDL3's ``REPEAT`` action and the browser's repeated ``keydown`` all arrive here as ``pressed=True``, indistinguishable from a fresh keystroke. The router cannot see the repeat flag, but it can see that the key never came back up, which is the same fact: a press of a key already held is an echo. Traversal is deliberately NOT gated on this -- a held Tab should walk the tab order, as it does everywhere else. The act-once fallbacks are: activation, which presses a button, and dismissal, which closes an overlay. Repeating either turns one keystroke into a confirmed dialog or a chain of dismissals. Modifiers are stripped, so a release that arrives after the modifier was let go still clears the hold. If a release is missed entirely (the window losing focus mid-keystroke), the next press of that key is read as an echo and skipped, and its release restores normal service. """ key = event.key.rsplit("+", 1)[-1] if not key: return False if not event.pressed: self._keys_down.discard(key) return False if key in self._keys_down: return True self._keys_down.add(key) return False def _matches(self, action: str, key: str) -> bool: """True when ``key`` is bound to a UI navigation action on this tree's map.""" from .navigation import ui_action_matches tree = self._tree return ui_action_matches(action, key, tree.input_map if tree is not None else None) def _navigate(self, scope_root, event) -> bool: """Run focus traversal if the key is bound to one of the traversal actions.""" if self._matches("ui_focus_next", event.key): self._traverse_focus(scope_root) return True if self._matches("ui_focus_prev", event.key): self._traverse_focus(scope_root, reverse=True) return True return False def _activate_focus_owner(self, scope_root, event, *, echo: bool = False) -> bool: """Activate the focus owner when the key is bound to ``ui_accept``. Runs on the key PRESS only (both callers gate on ``event.pressed``), and not on the OS auto-repeat of a press already seen (``echo``, from :meth:`_track_key_hold`), so a single keystroke activates exactly once however long it is held down before release. The owner had first refusal: it claims the key by setting ``event.handled``, and this never runs then. So a focused ``TextEdit`` keeps Enter for submit, a ``CodeTextEdit`` keeps it for a newline, and a focused ``Button`` -- which wants neither -- is pressed. Only the focus owner is ever activated; there is no default-button rule, so a dialog that wants Enter in its text field to confirm connects ``text_submitted``. The owner must still be legitimately focusable at this instant -- :func:`~.focus.is_focusable`, the same authority traversal uses, so a disabled or hidden owner is skipped. Neither state releases focus on its own, so a control can go dark while still owning the key; every other routing path in this file gates on visibility, and activation, which acts rather than merely accepts, has more reason to than any of them. """ from .focus import is_focusable if echo or not self._matches("ui_accept", event.key): return False focused = self._focused_control if focused is None or not is_focusable(focused) or not self._is_descendant_of(focused, scope_root): return False return bool(focused.activate()) def _traverse_focus(self, scope_root, *, reverse: bool = False) -> None: """Move focus to the next (or previous) tab stop inside ``scope_root``. With no focus owner in the scope the first (last, when reversing) tab stop is elected, which is what an overlay that just opened wants. """ from .focus import next_focus target = next_focus(scope_root, self._focused_control, reverse=reverse) if target is not None: 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) # A touch carries no modifiers of its own; a keyboard held alongside it # is real state and reaches the control the same way it does on a mouse. ctrl, shift, alt, meta = self._modifiers(None, None, None, None) mods = {"ctrl": ctrl, "shift": shift, "alt": alt, "meta": meta} 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, **mods) 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, **mods) 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, **mods) 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)