Source code for simvx.core.ui.ui_input

"""UIInputManager: UI input routing, focus, overlay scope, hit-testing.

All UI input flows through one contract: ``Node.on_gui_input(event)``, which
``Control`` extends with the ``gui_input`` signal.
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 returning True from ``on_gui_input``; all three take that
one path (``_bubble``).
What is "under the cursor" is a chain rather than one control (``_hit_chain``):
a ``pointer_filter`` of ``PASS`` is hit-testable without blocking, so the
control behind it is a candidate too and is offered the event once the nearer
chain has declined it. ``STOP`` ends the chain and ``IGNORE`` never joins it.
That chain is the draw walk run backwards, so ``CanvasLayer.layer`` and
``z_index`` decide what is in front for the pointer exactly as they do for the
eye: if it can be seen it can be clicked.
Mouse motion does not bubble. It reaches the node holding the pointer while a
capture is in force 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.
Every finger and a pen are pointers of their own and arrive the same way the
mouse does. A control whose ``accepts_pointers`` is ``PRIMARY`` is offered the
primary pointer only, which is the mouse or whatever stands in for it; ``ALL``
is offered every pointer, each on its own id.
Capture is per pointer and lives on ``Node`` (``capture_pointer`` /
``release_pointer``), in one table keyed by pointer id: 0 is the mouse, and
every finger and pen has the opaque id the router gave it.
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 input does what is not decided here: the router asks ``ui/navigation.py``
whether what arrived is bound to ``ui_focus_next``, ``ui_focus_prev``,
``ui_accept``, ``ui_cancel`` or one of the four directions, so navigation is
rebindable through ``InputMap`` like any other action and any device can drive
it. A controller arrives through ``ui_pad`` and moves focus by geometry
(``focus.focus_neighbour``) rather than along the tab order, because a D-pad
points at the screen.
"""

from __future__ import annotations

import logging
import math
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import replace
from time import monotonic
from typing import TYPE_CHECKING, Any

from ..debug import _debug_log_event, _debug_log_focus, _debug_log_hit
from ..input.controls import DeviceButton, Pointer, key_code
from ..input.enums import CursorShape, FocusReason, PointerPhase, PointerType
from ..input.events import KeyEvent, PointerEvent, TextEvent
from ..math.types import Vec2
from ..node import Node
from .enums import AcceptsPointers, FocusMode, PointerFilter

if TYPE_CHECKING:
    from .core import Control

log = logging.getLogger(__name__)

#: How long a direction must be held before it starts repeating, in seconds,
#: and how fast it repeats after that. The pair a keyboard's own auto-repeat
#: uses, so a held D-pad walks a menu at the speed a held arrow key would.
NAV_REPEAT_DELAY = 0.4
NAV_REPEAT_INTERVAL = 0.09

# Lazy-cached references for circular imports
_ui_Control: type[Control] | None = None


def _get_control() -> type[Control]:
    global _ui_Control
    if _ui_Control is None:
        from . import Control

        _ui_Control = Control
    return _ui_Control


def _pointer_kind(event) -> str:
    """The debug log's name for a pointer event's phase."""
    return "mouse_press" if event.phase is PointerPhase.DOWN else "mouse_release"


def _kind_ok(node: Control, event: Any) -> bool:
    """Whether *node* is under a pointer of *event*'s kind: its ``pointer_types`` allow it, or name none."""
    kinds = node.pointer_types
    return kinds is None or not isinstance(event, PointerEvent) or event.pointer_type in kinds


def _hears(node: Node | None) -> bool:
    """Whether the targeted phase may deliver to *node* at this instant.

    The switch that stops a node processing is the switch that deafens it, so
    a paused control is not offered the pointer, the key or the activation,
    and ``None`` (nothing focused, nothing under the cursor) reads the same
    way as deaf at every call site.
    """
    return node is not None and node._can_receive_input()


class _Taken:
    """The owner of a press the router spent itself: its release is claimed and delivered to nobody."""

    __slots__ = ()

    def __repr__(self) -> str:
        return "<taken by the router>"


_TAKEN = _Taken()


def _holds(node: Node) -> bool:
    """Whether *node* may go on owning a press: in the tree, hearing, visible and enabled.

    A press owner that stops hearing (paused, its processing switched off, a
    script error), hides or is disabled is cancelled at the frame's audit
    rather than suspended: a drag that silently resumed after a pause would
    act on a gesture the player finished long ago.
    """
    if not node._can_receive_input():
        return False
    if not getattr(node, "visible_in_tree", True):
        return False
    return not getattr(node, "disabled", False)


def _tell_cancel(owner: Node, event: KeyEvent | PointerEvent) -> None:
    """Tell *owner* its press was cancelled, past the gates that would otherwise keep a disabled control deaf.

    A disabled control acts on no input, and that is exactly why it must hear
    this one: the press it began is over, and a button left looking pressed or
    a drag left half done is the bug the cancel exists to prevent.
    """
    Node._internal_gui_input(owner, event)


def _takes(control: Control, event) -> bool:
    """Whether *control* is offered *event* at all: a secondary pointer reaches only ``ALL``.

    A second finger on a button is not a second press of it, so an ordinary
    widget hears the primary pointer alone; a multi-touch widget hears every
    finger. Anything that is not a pointer is offered to everyone.
    """
    return control.accepts_pointers is AcceptsPointers.ALL or not isinstance(event, PointerEvent) or event.is_primary


def _cursor_shape_at(node: Node | None) -> CursorShape:
    """The shape *node* shows: its own ``cursor_shape``, else the nearest ancestor's, else ``ARROW``."""
    control_cls = _get_control()
    while node is not None:
        if isinstance(node, control_cls):
            shape: CursorShape | None = node.cursor_shape
            if shape is not None:
                return shape
        node = node.parent
    return CursorShape.ARROW


def _blocker(chain: list[Control]) -> Control | None:
    """The ``STOP`` control a hit chain ended at, or ``None``.

    :meth:`UIInputManager._hit_chain` stops at the first blocker, so only the
    last entry can be one. A blocker took the pointer whether or not it did
    anything with it: ``STOP`` means nothing behind it is under the pointer,
    and that is as true of the 3D scene behind the UI as of another control.
    """
    last = chain[-1] if chain else None
    return last if last is not None and last.pointer_filter is PointerFilter.STOP else None


[docs] class UIInputManager: """Routes UI input events to controls: focus, hover, pointer capture, overlay scope.""" def __init__(self): self._focused_control: Node | None = None #: Why focus last moved. self._focus_reason = FocusReason.CODE # Who owns each press in progress, keyed by ``(source id, press)``: a # pointer by its id, a key by ``("key", key)``. A press belongs to its # source, since two windows onto one tree both call their mouse # pointer 0. One table for every kind, because ownership means the # same thing whatever was pressed: what follows the press, its moves # and its release, goes to the owner and nowhere else, and an owner # that can no longer hold it is cancelled, never suspended. A pointer # capture is an entry here; so is a key a control claimed. _TAKEN # owns a press the router itself spent (Tab traversal, activation), so # its release is claimed and delivered to nobody. self._owners: dict[tuple[int, Any], Node | _Taken] = {} #: The source of the event being dispatched, so a widget calling #: ``capture_pointer()`` files its claim under the right window #: without having to restate what the event already says. self._dispatch_source: int = 0 #: The pointer of the event being dispatched, so ``capture_pointer()`` #: with no argument takes the pointer the widget is handling: a #: slider dragged by a finger captures that finger, not the mouse. self._dispatch_pointer: int = 0 #: The captures a finger's claimed press took without asking, as #: ``(source, pointer)``: held, but not grabbed the way a joystick #: grabs its finger, so a long press may still be recognised on one. self._held_fingers: set[tuple[int, int]] = set() self._last_mouse_pos = Vec2() # Hover is derived state, not an event, so it is recomputed once a # frame from wherever each pointer ended up rather than per motion # sample: nothing reads it between samples, and the walk it costs was # the bulk of the motion path. One position per pointer per source, # because two windows have two cursors and either may be over a # control, and a pen hovers where the mouse is not. A finger cannot # hover, so it is here from its press until just after its lift. self._hover_pos: dict[tuple[int, int], tuple[float, float]] = {} #: What each hovering pointer is: only a mouse or a pen focuses by hover. self._hover_kind: dict[tuple[int, int], PointerType] = {} #: The topmost HOVER node each pointer was last over, so only a change counts. self._hover_targets: dict[tuple[int, int], Node | None] = {} #: A hover target waiting for a held key, button or capture to end. self._hover_pending: Node | None = None #: The key whose arrival is bringing a stale hover up to date, not held against it. self._arriving_key: int | None = None #: The windows without the operating system's keyboard focus. self._unfocused_sources: set[int] = set() self._hover_dirty = False #: The mouse or pen whose position decides the cursor's shape: the one #: that moved last, as ``(source, pointer)``. self._cursor_slot: tuple[int, int] | None = None #: Set when something the shape depends on changed without the pointer #: moving: a control naming a shape, a capture starting or ending. self._cursor_dirty = False self._keys_down: set[DeviceButton] = set() # keys held, so auto-repeat can be told from a new press #: The node that claimed the key press being routed, which becomes its owner. self._claimant: Node | None = None #: Each pointer's press in progress, so a cancel can name the button it ends. self._downs: dict[tuple[int, int], PointerEvent] = {} # The direction the pad is currently holding, and when it next repeats. # A keyboard repeats itself through the OS; a stick or a D-pad reports # held state and nothing else, so the repeat is generated here. self._nav_held: str | None = None self._nav_repeat_at: float = 0.0 # 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 @contextmanager def _from_source(self, source_id: int, pointer_id: int = 0) -> Iterator[None]: """Name the source and pointer of the event being dispatched, for the duration. A widget calling ``capture_pointer()`` from inside its handler means the pointer of the event it is handling, and the event already says which window and which pointer that is. Reading it here rather than asking every widget to pass it keeps the fact on the event, where the typed family put it. """ previous = self._dispatch_source, self._dispatch_pointer self._dispatch_source = source_id self._dispatch_pointer = pointer_id try: yield finally: self._dispatch_source, self._dispatch_pointer = previous
[docs] def reset(self): """Reset transient state (called on scene change).""" self._owners.clear() self._held_fingers.clear() self._downs.clear() self._keys_down.clear() self._nav_held = None self._cursor_dirty = True
# ------------------------------------------------------------------ capture def _captor_for(self, source_id: int, pointer_id: int) -> Node | None: """Who holds *pointer_id*, for an event or a query from *source_id*. Source 0 is not a window, it is "nobody said": a hand-built event and a capture taken outside a dispatch both carry it. So it matches in either direction, an unattributed event reaching whichever window holds the pointer and an attributed one reaching an unattributed capture. Two real windows attribute everything, so the wildcard never fires between them; one window, which is every game, has nothing to confuse. """ held = self._owners.get((source_id, pointer_id)) if isinstance(held, Node): return held if source_id: held = self._owners.get((0, pointer_id)) return held if isinstance(held, Node) else None return next( (node for (_s, press), node in self._owners.items() if press == pointer_id and isinstance(node, Node)), None, )
[docs] def captor(self, pointer_id: int | None = None): """The node holding *pointer_id*, or None. Pointer 0 is the mouse. Inside a dispatch, the holder on the source the event came from, which is the one a widget asking mid-handler means, and with no pointer named, the pointer of that event. """ return self._captor_for(self._dispatch_source, self._dispatch_pointer if pointer_id is None else pointer_id)
[docs] def grabbed(self, source_id: int, pointer_id: int) -> bool: """Whether a node took *pointer_id* with ``capture_pointer``, rather than by claiming a finger's press.""" if self._captor_for(source_id, pointer_id) is None: return False return (source_id, pointer_id) not in self._held_fingers and (0, pointer_id) not in self._held_fingers
[docs] def capture(self, node, pointer_id: int | None = None) -> None: """Route every event from *pointer_id* to *node* until it is released. With no pointer named, the pointer of the event being dispatched, and the mouse outside a dispatch. """ pointer = self._dispatch_pointer if pointer_id is None else pointer_id self._owners[(self._dispatch_source, pointer)] = node self._held_fingers.discard((self._dispatch_source, pointer)) self._cursor_dirty = True
[docs] def release(self, node, pointer_id: int | None = None) -> None: """Give *pointer_id* back, if *node* is what holds it. Matched the way :meth:`_captor_for` matches, so a widget that took the pointer under one spelling of "nobody said" gives it back under the other. With no pointer named, the pointer of the event being dispatched, as :meth:`capture`. """ if pointer_id is None: pointer_id = self._dispatch_pointer if self._captor_for(self._dispatch_source, pointer_id) is not node: return for key in [k for k, held in self._owners.items() if held is node and k[1] == pointer_id]: del self._owners[key] self._cursor_dirty = True
[docs] def notify_subtree_removed(self, node): """Release focus and any pointer capture a leaving subtree holds. Called from ``Node._exit_tree`` so that a destroyed or reparented focused control never lingers as the focus owner and keeps receiving keyboard events, and a captor carried out of the tree stops being handed the pointer: a node the tree no longer holds receives nothing, and capture is a second delivery path that does not inherit that for free. 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, node): self._set_focused_control(None, FocusReason.LEFT) leaving = [ k for k, held in self._owners.items() if isinstance(held, Node) and self._is_descendant_of(held, node) ] for key in leaving: del self._owners[key]
[docs] def source_captures(self, source_id: int) -> list[int]: """The pointers *source_id* may cancel: its own, and the unattributed. What a router asks before cancelling. It speaks for one window, and another window's pointer is not its to take back. Source 0 is not a window, it is "nobody said", which is what a hand-built event carries; no window can claim those and none can leave them stranded either, so every source cancels them. """ pointers: list[int] = [] for source, press in self._owners: pointer = press if isinstance(press, int) else press[1] if press[0] == "up" else None if pointer is not None and source in (source_id, 0) and pointer not in pointers: pointers.append(pointer) return pointers
[docs] def cancel_capture(self, pointer_id: int, event) -> Node | None: """Take *pointer_id* back on *event*'s source, and tell whoever held it. For input interrupted rather than finished: the window loses focus, the system claims the gesture, the finger is lifted off the edge of the glass. The node hears a cancel and not a release, so the button does not fire and the slider does not commit, but it knows to stop dragging. Silence is what left a splitter believing the divider was still under the cursor. Returns the node that held the pointer, or ``None``. """ source_id = getattr(event, "source_id", 0) node = self._captor_for(source_id, pointer_id) if node is not None: for key in [k for k, held in self._owners.items() if held is node and k[1] == pointer_id]: del self._owners[key] # The control that took the press without capturing it owns its # release, so it hears the cancel too, once. owners = [node] if node is not None else [] for key in [k for k in self._owners if isinstance(k[1], tuple) and k[1][0] == "up" and k[1][1] == pointer_id]: if key[0] not in (source_id, 0) and source_id: continue held = self._owners.pop(key) if isinstance(held, Node) and held not in owners: owners.append(held) self._downs.pop((source_id, pointer_id), None) for owner in owners: if owner.tree is not None: with self._from_source(source_id, pointer_id): owner._internal_gui_input(event) return node if node is not None else (owners[0] if owners else None)
[docs] def audit_owners(self) -> None: """Cancel every press whose owner can no longer hold it: once a frame, and free with none held. An owner that hides, is disabled, stops hearing (paused, processing switched off) or has raised is told its press was cancelled, a ``PointerPhase.CANCEL`` for a pointer and a release marked ``cancelled`` for a key, and loses it: the rest of the gesture reaches nobody, and resuming does not pick it up again. An owner a script error disabled is not told, because it will never run again. """ told: set[tuple[int, Node, int]] = set() for slot, owner in list(self._owners.items()): if isinstance(owner, _Taken) or _holds(owner): continue source_id, press = slot if isinstance(press, int): # A capture: the pointer is free again, and its release is nobody's. del self._owners[slot] pointer = press else: # The press is spent: what follows it is taken and heard by nobody. self._owners[slot] = _TAKEN pointer = press[1] if press[0] == "up" else -1 if owner.tree is None or owner._script_error: continue if press[0] == "key" if not isinstance(press, int) else False: _tell_cancel(owner, KeyEvent(key=press[1], pressed=False, cancelled=True, source_id=source_id)) continue # A pointer press, captured or merely owned: one cancel however it is held. if (source_id, owner, pointer) in told: continue told.add((source_id, owner, pointer)) down = self._downs.get((source_id, pointer)) if down is None: continue event = replace(down, phase=PointerPhase.CANCEL) with self._from_source(source_id, pointer): _tell_cancel(owner, event)
[docs] def release_grabs(self) -> None: """Drop every pointer capture without telling the nodes anything. Teardown, where there is nobody left to tell. A capture taken away while the scene is still running goes through :meth:`cancel_capture` instead, so the node holding it hears that the gesture is over. """ self._owners.clear() self._held_fingers.clear()
@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: Node) -> Node: """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 def _focus_owner(self) -> Node | None: """The focus owner for delivery, or ``None`` while it cannot receive input. A pause takes delivery away, not focus: the owner keeps the ring and the text cursor and has them back the moment it wakes, which is the same reason a context change presses nothing it did not see pressed. Traversal reads :attr:`_focused_control` directly, because moving away from a paused owner is a move the router makes rather than input the owner hears. """ focused = self._focused_control return focused if _hears(focused) else None # ------------------------------------------------------------------ entry point
[docs] def ui_key(self, root: Node | None, event: KeyEvent) -> Node | None: """Route a :class:`~simvx.core.input.events.KeyEvent` to the UI. The UI receives the object the tree does, so a widget reads the physical key, its label and the four modifier flags off the same event a broadcast handler sees. Returns the node that took the key, or ``None`` when the UI let it through. """ tree = self._tree if self._hover_dirty and tree is not None and tree._hover_focusables and root: # A key goes to whoever the pointer has just made the focus owner, # so a stale hover is brought up to date before the key is routed; # the key arriving is not one held against it. self._arriving_key = key_code(event.key) try: self.flush_hover(root) finally: self._arriving_key = None if not root: return None _get_control() return self._handle_keyboard_event(root, event)
[docs] def ui_text(self, root: Node | None, event: TextEvent) -> Node | None: """Route a :class:`~simvx.core.input.events.TextEvent` to the UI. Text is what the player meant to type, after the layout, the dead keys and the IME have had their say, so a text field inserts ``event.text`` and never assembles it from key presses. Returns the node that took the text, or ``None``. """ if not root or not event.text: return None _get_control() return self._handle_text_event(root, event)
[docs] def ui_pointer(self, root: Node | None, event: PointerEvent) -> Node | None: """Route a :class:`~simvx.core.input.events.PointerEvent` to the UI. The UI receives the object the tree does, so a widget reads the phase, the button, the pressure and the pointer id off the same event a broadcast handler sees. A locked pointer has no screen position to hit test with and is left to the game. Returns the node that took the pointer, or ``None`` when the UI let it through. The targeted phase casts its pick ray only on the ``None``, so a click on a button no longer also picks the world behind it. A finger, a pen and the mouse all take this one path. A control is offered a secondary pointer only when its ``accepts_pointers`` is ``ALL``. A cancel reaches whoever holds the pointer and nobody else, because it ends a gesture rather than landing anywhere. A finger hovers from its press until its lift, and a pen stops hovering when it leaves the digitiser's range. """ if not root or event.locked: return None if event.is_primary: self._last_mouse_pos = Vec2(event.position[0], event.position[1]) phase = event.phase if phase is PointerPhase.DOWN: self._downs[(event.source_id, event.pointer_id)] = event elif phase is PointerPhase.UP or phase is PointerPhase.CANCEL: self._downs.pop((event.source_id, event.pointer_id), None) with self._from_source(event.source_id, event.pointer_id): if phase is PointerPhase.MOVE: self._handle_mouse_move(root, event) return None if phase is PointerPhase.CANCEL: self._stop_hovering(event) return self.cancel_capture(event.pointer_id, event) if phase is PointerPhase.LEAVE: self._stop_hovering(event) return None if event.button is not None and (phase is PointerPhase.DOWN or phase is PointerPhase.UP): taken = self._handle_mouse_event(root, event) if phase is PointerPhase.UP and event.pointer_type is PointerType.TOUCH: # A finger's id dies with its lift, so nothing can go on # holding it, whoever captured it. self._stop_hovering(event) self._drop_finger(event) return taken return None
def _hold_finger(self, event: PointerEvent, consumer: Node | None) -> None: """A finger belongs to the control that took its press, until it lifts. A finger is a direct manipulation: what it pressed is what it is dragging, so the control that claimed the press keeps the finger's moves and its lift wherever the finger goes, as if it had called ``capture_pointer``. A control that already captured it explicitly keeps its own capture, and a press nobody claimed follows the hit test as a mouse does. """ if event.pointer_type is not PointerType.TOUCH or event.phase is not PointerPhase.DOWN or consumer is None: return key = (event.source_id, event.pointer_id) if key not in self._owners and consumer.tree is not None: self._owners[key] = consumer self._held_fingers.add(key) def _drop_finger(self, event: PointerEvent) -> None: """Forget every capture of a finger that has lifted.""" for key in [k for k in self._owners if k[1] == event.pointer_id and k[0] in (event.source_id, 0)]: del self._owners[key] self._held_fingers.discard(key) def _stop_hovering(self, event: PointerEvent) -> None: """Take *event*'s pointer out of the hover, recomputed at the next flush. A finger that lifts is no longer over anything, and nor is a pen that left range: hover is where a pointer *is*, and these are nowhere. """ self._hover_kind.pop((event.source_id, event.pointer_id), None) self._hover_targets.pop((event.source_id, event.pointer_id), None) if self._hover_pos.pop((event.source_id, event.pointer_id), None) is not None: self._hover_dirty = True # ------------------------------------------------------------------ mouse press / release def _handle_mouse_event(self, root: Node, event: PointerEvent) -> Node | None: """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. Returns the node that took the pointer: the captor, the control that claimed the event, or the blocker the chain ended at. ``None`` means the pointer reached nothing that stops it, and the world behind is free to be picked. A blocker stops a secondary finger too, whether or not it takes one: what cannot be seen past cannot be touched past. """ overlay_scope = self._overlay_scope() if overlay_scope is not None: return self._dispatch_mouse_in_subtree(overlay_scope, event) captor = self._captor_for(event.source_id, event.pointer_id) if captor is not None: # A captor that can no longer hold the pointer was cancelled at the # frame's audit; one still here hears the rest of its gesture. if _holds(captor): captor._internal_gui_input(event) _debug_log_event(_pointer_kind(event), event, captor, "grabbed") return captor release_slot = (event.source_id, ("up", event.pointer_id, event.button)) if event.phase is PointerPhase.UP: # A release belongs to its press: the control that took the press # hears it wherever the pointer went, and a press that was cancelled # has its release taken and heard by nobody. owner = self._owners.pop(release_slot, None) if isinstance(owner, _Taken): return root if owner is not None: if _holds(owner): owner._internal_gui_input(event) return owner chain = self._hit_chain(root, event.position, event) target_control = chain[0] if chain else None _debug_log_hit(event.position, target_control) self._update_mouse_over_states(root, event.position, (event.source_id, event.pointer_id)) if not chain: _debug_log_event(_pointer_kind(event), event, None, "miss") return None consumer = self._bubble_chain(chain, event, root) _debug_log_event(_pointer_kind(event), event, target_control, "delivered") self._focus_on_press(event, target_control, consumer) self._hold_finger(event, consumer) if event.phase is PointerPhase.DOWN and consumer is not None: self._owners[release_slot] = consumer return consumer if consumer is not None else _blocker(chain) 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. A secondary finger moves no focus: a thumb resting on an on-screen pad must not pull the focus away from the field the other thumb is typing into. """ if event.is_primary and event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN: self._set_focused_control(consumer if consumer is not None else target, FocusReason.CLICK) def _bubble(self, target: Node | None, event, scope_root: Node | None, seen: set[int] | None = None) -> Node | None: """Offer *event* to *target*, then to each ancestor up to *scope_root*. Returns the control that consumed it (the one whose ``on_gui_input`` returned True), 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: answer True and the chain stops there. Only controls whose class overrides ``on_gui_input`` or declares an ``@on_gui_input`` handler 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. ``seen`` carries the nodes an earlier bubble already offered this event to, so a hit chain that passes through a control and then reaches its own parent does not deliver to that parent twice. The walk stops at the first node the tree no longer holds. A handler can tear its own ancestor out while it runs, and the parent link outlives the tree membership by an instant; without this the bubble climbs into a subtree the tree has already let go of, which is the one rule the broadcast gets for free and this path does not. An ancestor that cannot receive input is stepped over rather than stopping the walk: a child that runs through a pause is still under a parent that does not, and the panel above them both may run too. """ Control = _get_control() node = target while node is not None: if node.tree is None: break if ( isinstance(node, Control) and node.pointer_filter is not PointerFilter.IGNORE and _takes(node, event) and _kind_ok(node, event) and _hears(node) ): if seen is None or id(node) not in seen: if seen is not None: seen.add(id(node)) if type(node)._simvx_hears_gui: if node._internal_gui_input(event) is True: return node if node is scope_root: break node = node.parent return None def _bubble_chain(self, chain: list[Control], event, scope_root: Node | None) -> Node | None: """Bubble *event* from each hit candidate in turn, nearest first. A ``PASS`` control does not stop the pointer, so the candidate behind it is offered the event next. Delivery stops at the first claim, and no control is offered the same event twice however many candidates share an ancestor. """ seen: set[int] = set() for target in chain: consumer = self._bubble(target, event, scope_root, seen) if consumer is not None: return consumer return None def _dispatch_mouse_in_subtree(self, subtree_root: Node, event: PointerEvent) -> Node | None: """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``. Returns the node that took the pointer. An open overlay scope is modal, so it owns every pointer that reaches it: a click that misses every widget in the scope still dismisses the chain rather than falling through to the scene behind it, and the scope root is what took it. """ captor = self._captor_for(event.source_id, event.pointer_id) if captor is not None and self._is_descendant_of(captor, subtree_root): if _holds(captor): captor._internal_gui_input(event) _debug_log_event( _pointer_kind(event), event, captor, "grabbed", ) return captor chain = self._hit_chain(subtree_root, event.position, event) target = chain[0] if chain else None _debug_log_hit(event.position, target) self._update_mouse_over_states(subtree_root, event.position, (event.source_id, event.pointer_id)) if chain: consumer = self._bubble_chain(chain, event, subtree_root) _debug_log_event( _pointer_kind(event), event, target, "delivered", ) self._focus_on_press(event, target, consumer) self._hold_finger(event, consumer) return consumer if consumer is not None else (_blocker(chain) or subtree_root) _debug_log_event( _pointer_kind(event), event, target, "miss", ) # Click landed outside every widget in the scope subtree: dismiss the chain. if event.is_primary and event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN: self._dismiss_overlay_chain(subtree_root) return 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.emit() # 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) def _confirm_overlay_chain(self, owner): """Fire ``owner.confirm_requested`` for an unconsumed ``ui_accept``. The mirror of :meth:`_dismiss_overlay_chain`: ``ui_cancel`` that nobody claimed means "close this", and ``ui_accept`` that nobody claimed means "the chain owner was asked to confirm". It runs only after the focused descendant declined the key and :meth:`_activate_focus_owner` found nothing to press, so it takes Enter from no widget that wants it. Unlike dismissal it closes nothing and unwinds nothing. This is not a default-button rule: the router does not choose a widget to activate, it reports the keystroke to the owner, which decides what confirming means. """ tree = self._tree if tree is None or not tree.overlays: return if tree.overlays.topmost_capturing() is None: return cr = getattr(owner, "confirm_requested", None) if cr is not None: cr.emit() # ------------------------------------------------------------------ mouse move def _handle_mouse_move(self, root: Node, event): """Deliver the move to the control under the cursor, and note the hover. 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 pointer, which takes the move ahead of any hit-test. Hover is separate from delivery, and separate in time: this records where the pointer got to and :meth:`flush_hover` recomputes ``mouse_over`` once, at the top of the frame, from the last position of all of them. It is derived state rather than an event, nothing reads it between samples, and recomputing it per sample was the bulk of what motion cost. """ if event.pointer_type is not PointerType.TOUCH or (event.source_id, event.pointer_id) in self._hover_pos: # A finger is over something only while it is down, which its # press recorded; a move the press never saw hovers nothing. self._hover_pos[(event.source_id, event.pointer_id)] = event.position self._hover_kind[(event.source_id, event.pointer_id)] = event.pointer_type self._hover_dirty = True if event.pointer_type is not PointerType.TOUCH: self._cursor_slot = (event.source_id, event.pointer_id) captor = self._captor_for(event.source_id, event.pointer_id) if captor is not None: captor._internal_gui_input(event) return scope_root = self._scope_root(root) chain = self._hit_chain(scope_root, event.position, event) _debug_log_hit(event.position, chain[0] if chain else None) for target in chain: if target.tree is None: continue # a handler before it tore this one out of the tree if not _takes(target, event): continue if target._internal_gui_input(event) is True: break # ------------------------------------------------------------------ scroll
[docs] def ui_scroll(self, root: Node | None, event) -> Node | None: """Route a :class:`~simvx.core.input.events.ScrollEvent` to the UI. The wheel arrives as the event the tree receives rather than as a key named after it, so a control reads the position it turned over and how far it turned, and a trackpad's fractional delta survives the trip. Returns the control that took the notch, or ``None``. """ if not root: return None self._last_mouse_pos = Vec2(event.position[0], event.position[1]) if not event.scroll[1]: # A purely horizontal notch reaches no control. Every consumer asks # only whether the vertical delta is above zero, so a zero would # read as a scroll down and send lists and viewports the wrong way. # Horizontal scrolling is a feature nothing implements yet, and it # starts by giving those consumers a sideways answer to ask for. return None return self._handle_scroll_event(root, event)
def _handle_scroll_event(self, root: Node, event) -> Node | None: """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) chain = self._hit_chain(scope_root, event.position) _debug_log_hit(event.position, chain[0] if chain else None) if not chain: return None return self._bubble_chain(chain, event, scope_root) or _blocker(chain) # ------------------------------------------------------------------ keyboard def _handle_keyboard_event(self, root: Node, event: KeyEvent) -> Node | None: """Route a key press or release. 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: the focused control, then ``ui_focus_next`` / ``ui_focus_prev`` traversal and ``ui_accept`` activation within the tree. A global shortcut is not the router's: it is a ``@on_input(..., capture=True)`` handler, which ran before this. The focused control claims a key by returning True, 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. Returns the node that took the key. Whatever answers here has taken the keystroke off the game: the action bound to the same key does not open, raise an edge or reach a handler. """ echo = self._track_key_hold(event) if not event.pressed: return self._release_key(root, event) if echo: owner = self._echo_owner(event) if owner is not None: return owner self._claimant = None taken = self._press_key(root, event, echo) if taken is not None and not echo: self._owners[(event.source_id, ("key", event.key))] = self._claimant or _TAKEN self._claimant = None return taken def _echo_owner(self, event: KeyEvent) -> Node | None: """Hand a held key's auto-repeat to the control that claimed its press, and return that control. A key's repeats belong to its press as its release does: the control that claimed the press hears them, and one it declines is still its own, so a handler that acts once per press (an ``@on_gui_input`` key target without ``repeat=True``) never lets the repeats fall through to traversal or the game. None when no control claimed the press, or the router spent it itself, and the repeat routes as a press does. """ owner = self._owners.get((event.source_id, ("key", event.key))) if owner is None and event.source_id: owner = self._owners.get((0, ("key", event.key))) if owner is None or isinstance(owner, _Taken): return None if _holds(owner): owner._internal_gui_input(event) return owner def _press_key(self, root: Node, event: KeyEvent, echo: bool) -> Node | None: """Route a key press: the overlay scope, then the focus owner and the fallbacks.""" overlay_scope = self._overlay_scope() if overlay_scope is not None: return self._handle_keyboard_in_scope(overlay_scope, event, echo=echo) focused = self._focus_owner() if focused is None: return None if self._offer(focused, event): return focused if self._is_descendant_of(focused, root): if self._navigate(root, event): return self._focused_control or root if self._activate_focus_owner(root, event, echo=echo): return focused return None def _offer(self, node: Node, event: KeyEvent) -> bool: """Offer a key press to *node*, recording it as the owner when it claims.""" if node._internal_gui_input(event) is True: self._claimant = node return True return False def _release_key(self, root: Node, event: KeyEvent) -> Node | None: """A key's release goes to what took its press, and to nothing else. The control that claimed the press hears the release even after focus has moved on, and a control that gained focus between the two never hears a release it did not see pressed. A press the router spent itself (traversal, activation) has its release claimed and heard by nobody. A release whose press nobody took reaches no control: in an open modal scope it is still taken, since the dialog owns the keyboard, and otherwise it is the game's. """ owner = self._owners.pop((event.source_id, ("key", event.key)), None) if owner is None and event.source_id: owner = self._owners.pop((0, ("key", event.key)), None) if owner is None: scope: Node | None = self._overlay_scope() return scope if isinstance(owner, _Taken): return root if _holds(owner): owner._internal_gui_input(event) elif owner.tree is not None and not owner._script_error: # The owner stopped being able to hold the press while the key was # down and the frame's audit has not run yet: cancelled, as it would # have been there, rather than handed a keystroke it can no longer act on. _tell_cancel(owner, replace(event, cancelled=True)) return owner def _handle_text_event(self, root: Node, event: TextEvent) -> Node | None: """Route a typed character to the focus owner. Text has no fallbacks of its own: navigation, activation and dismissal are bound to keys, and a character that reaches none of them is simply text nobody wanted. What it keeps from the key path is the modal rule, that an open capturing overlay owns the keyboard whether or not a widget in it acted. """ scope_root: Node | None = self._overlay_scope() focused = self._focus_owner() if scope_root is not None: if focused is not None and self._is_descendant_of(focused, scope_root): if focused._internal_gui_input(event) is True: return focused elif _hears(scope_root) and scope_root._internal_gui_input(event) is True: return scope_root return scope_root if focused is None: return None return focused if focused._internal_gui_input(event) is True else None def _handle_keyboard_in_scope(self, scope_root: Node, event: KeyEvent, *, echo: bool = False) -> Node | None: """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`. An open modal scope owns the keyboard, so every key that reaches it is taken whether or not a widget in it acted: that is what modal means, and the game underneath must not read a keystroke aimed at the dialog over it. """ focused = self._focus_owner() # Focused descendant gets first crack. if focused is not None and self._is_descendant_of(focused, scope_root): if self._offer(focused, event): return focused elif _hears(scope_root): # Deliver to the scope root itself so it can act as a shortcut sink. if self._offer(scope_root, event): return scope_root # Router-level fallbacks. if self._navigate(scope_root, event): return scope_root if self._activate_focus_owner(scope_root, event, echo=echo): return scope_root if not echo and self._matches("ui_cancel", event): self._dismiss_overlay_chain(scope_root) elif not echo and self._matches("ui_accept", event): self._confirm_overlay_chain(scope_root) return scope_root def _track_key_hold(self, event: KeyEvent) -> 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. The hold is keyed by the physical key alone, so a release that arrives after the modifier was let go still clears it. 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 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, event: KeyEvent) -> bool: """True when ``event`` 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, event, tree.input_map if tree is not None else None) def _navigate(self, scope_root, event: KeyEvent) -> bool: """Run focus traversal if the key is bound to one of the traversal actions.""" from .navigation import UI_DIRECTIONS if self._matches("ui_focus_next", event): self._traverse_focus(scope_root) return True if self._matches("ui_focus_prev", event): self._traverse_focus(scope_root, reverse=True) return True for action, direction in UI_DIRECTIONS.items(): if self._matches(action, event): self._move_focus(scope_root, direction) return True return False # ------------------------------------------------------------------ controller
[docs] def ui_pad(self, root: Node | None, event) -> Node | None: """Route a controller button or axis to UI navigation. This is the whole of "a controller can drive a menu". It resolves through the action layer rather than by comparing button codes, so a game that rebinds ``ui_accept`` moves the pad binding with it, and a pad binding a game adds to one of these actions works without the UI knowing anything about that pad. A press acts once; a held direction repeats through :meth:`flush_nav_repeat`. Activation and dismissal never repeat, so one press of the bottom face button confirms one dialog however long it is held. Returns the node the press moved to, activated or dismissed, and ``None`` when navigation found nothing to do with it. Navigation that acted has spent the button: the game action bound to the same face button does not also fire, which is what keeps the bottom button from confirming a dialog and jumping at once. """ from .navigation import UI_ACCEPT, UI_CANCEL, UI_DIRECTIONS, UI_FOCUS_NEXT, UI_FOCUS_PREV from .navigation import ui_actions_for_pad_event as _resolve if root is None: return None tree = self._tree if tree is None: return None taken: Node | None = None for action, pressed in _resolve(event, tree.input, tree.input_map): direction = UI_DIRECTIONS.get(action) if direction is not None: if not pressed: if self._nav_held == action: self._nav_held = None continue self._nav_held = action self._nav_repeat_at = monotonic() + NAV_REPEAT_DELAY taken = self._move_focus(self._scope_root(root), direction) or taken continue if not pressed: continue scope_root = self._scope_root(root) if action == UI_FOCUS_NEXT: taken = self._traverse_focus(scope_root) or taken elif action == UI_FOCUS_PREV: taken = self._traverse_focus(scope_root, reverse=True) or taken elif action == UI_ACCEPT: taken = self._pad_accept(scope_root) or taken elif action == UI_CANCEL: taken = self._pad_cancel(scope_root) or taken return taken
def _pad_accept(self, scope_root: Node) -> Node | None: """Activate the focus owner, and confirm an overlay that nobody claimed. The same two steps ``ui_accept`` already takes on the keyboard, in the same order, so a pad and Enter do the same thing to the same dialog. """ from .focus import is_focusable focused = self._focus_owner() if focused is not None and is_focusable(focused) and self._is_descendant_of(focused, scope_root): activate = getattr(focused, "activate", None) if activate is not None and activate(): return focused if self._overlay_scope() is not None: self._confirm_overlay_chain(scope_root) return scope_root return None def _pad_cancel(self, scope_root: Node) -> Node | None: """Dismiss the open overlay chain, as ``ui_cancel`` does on the keyboard.""" if self._overlay_scope() is not None: self._dismiss_overlay_chain(scope_root) return scope_root return None
[docs] def flush_nav_repeat(self, root: Node | None) -> None: """Repeat a held direction, once per frame, while it is still held. Called from the tree's tick. The clock is :func:`time.monotonic` and not the scene clock, because a pause menu is exactly where a player holds a direction and the scene clock has stopped. """ if self._nav_held is None or root is None: return from .navigation import UI_DIRECTIONS now = monotonic() if now < self._nav_repeat_at: return self._nav_repeat_at = now + NAV_REPEAT_INTERVAL self._move_focus(self._scope_root(root), UI_DIRECTIONS[self._nav_held])
def _move_focus(self, scope_root: Node, direction) -> Node | None: """Move focus one step in *direction*, or elect a first owner if there is none. Returns the new focus owner, or ``None`` when there was nowhere to go. """ from .focus import focus_neighbour target = focus_neighbour(scope_root, self._focused_control, direction) if target is not None: self._set_focused_control(target, FocusReason.DIRECTION) return target def _activate_focus_owner(self, scope_root, event: KeyEvent, *, 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 returning True, 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): return False focused = self._focus_owner() if focused is None or not is_focusable(focused) or not self._is_descendant_of(focused, scope_root): return False activate = getattr(focused, "activate", None) return bool(activate()) if activate is not None else False def _traverse_focus(self, scope_root: Node, *, reverse: bool = False) -> Node | 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. Returns the new focus owner, or ``None`` when there was no tab stop. """ 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, FocusReason.TAB) return target # ------------------------------------------------------------------ hit-test def _hit_chain(self, root: Node, point, event: PointerEvent | None = None) -> list[Control]: """The controls under *point*, nearest first, ending at the first blocker. **Hit testing is the exact inverse of paint order**: if it can be seen it can be clicked. The walk therefore runs the draw walk backwards rather than deriving an order of its own, because two orderings that must agree eventually disagree, and the one that decides what the player sees is the one that has to win. ``Node._ordered_children`` is the draw walk's ordering hook, and it is what answers ``CanvasLayer.layer`` and ``z_index``. A node paints between its ``below`` and ``above`` bands, so the hit walk visits ``above`` in reverse, then the node, then ``below`` in reverse. On the fast path (nothing to band) a node paints before its children, so its children come first, the latest sibling nearest. :class:`~.enums.PointerFilter` decides what each control contributes. ``IGNORE`` is not in the chain at all and does not hide what is behind it. ``PASS`` joins the chain and the walk carries on past it, so a decorative panel is offered the event and so is the widget under it. ``STOP`` joins the chain and ends it, because nothing behind a blocker is under the pointer. **A finger also hits a touch target near it.** Given a touch *event*, a :attr:`~.core.Control.touch_target` smaller than the theme's ``min_hit_size`` is hit anywhere in its grown area, while its layout and its drawing stay as authored. The rules, in order: an exact hit on a control a player aims at (a touch target, or anything that takes focus) wins, because a thumb squarely on a text field is on the text field; otherwise, of the grown areas the point lands in, the one whose control centre is closest wins, and among equals the one painted in front. A grown area never reaches past a blocker in front of it, because the walk stops there before visiting what is behind. Closest centre rather than paint order or the first grown area found, because two neighbours' grown areas overlap exactly between them, and the neighbour the thumb is nearer is the one it meant. """ control_cls = _get_control() chain: list[Control] = [] # Touch targets whose grown hit area holds the point without their # rect doing so, as ``(distance to centre, visit order, control)``. finger = event if event is not None and event.pointer_type is PointerType.TOUCH else None near: list[tuple[float, int, Control]] = [] def visit(node) -> bool: """Collect hits in *node*'s subtree; True once a blocker is found.""" if not node.visible: return False # 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_cls) and node._clips_input and not node.is_point_inside(point): return False below, above = node._ordered_children() if below is None: for child in reversed(list(node.children)): if visit(child): return True else: for child in reversed(above): if visit(child): return True if ( isinstance(node, control_cls) and node.pointer_filter is not PointerFilter.IGNORE and _kind_ok(node, event) ): # A control that cannot hear the pointer is not under it # either: it neither takes the click, nor hides what is behind # it, nor takes focus from the press. Its children are visited # all the same, because one of them may run through the pause # when it does not. The hover highlight is the one thing this # does not reach, because ``_walk_mouse_over`` does not read # the chain; that is filed as a task of its own. if node.is_point_inside(point) and _hears(node): chain.append(node) if node.pointer_filter is PointerFilter.STOP: return True elif finger is not None and node.touch_target and node.is_hit(finger) and _hears(node): gx, gy, gw, gh = node.get_global_rect() near.append((math.hypot(point[0] - (gx + gw / 2), point[1] - (gy + gh / 2)), len(near), node)) if below is not None: for child in reversed(below): if visit(child): return True return False if root: visit(root) if near and not any(c.touch_target or c.focus_mode is not FocusMode.NONE for c in chain): # The visit order breaks a tie, and it is unique, so the control is never compared. chain.insert(0, min(near)[2]) return chain def _find_control_at_point(self, root: Node, point): """The nearest control under *point*, or None. The head of :meth:`_hit_chain`.""" chain = self._hit_chain(root, point) if root else [] result = chain[0] if chain else None _debug_log_hit(point, result) return result
[docs] def flush_hover(self, root: Node | None) -> None: """Recompute ``mouse_over`` once, from where the pointer ended up. The frame's motion samples set a position and a flag; this spends them. Hover is a cached query rather than an event, so a pointer that crossed four widgets in one frame leaves the one it stopped on hovered, and nothing in between ever reported itself so. The walk it costs happens once whatever the mouse rate is, and not at all in a frame the pointer did not move. """ if not self._hover_dirty: if self._hover_pending is not None: self._apply_hover_focus() if self._cursor_dirty and root is not None: self._refresh_cursor(self._scope_root(root)) return self._hover_dirty = False if root is None: return # With no pointer left the walk still runs: the last one leaving is # what takes the hover off the control it was over. scope = self._scope_root(root) self._walk_mouse_over(scope) tree = self._tree if tree is not None and tree._hover_focusables: self._follow_hover(scope) self._cursor_dirty = True self._refresh_cursor(scope)
[docs] def leave_hover(self, root: Node | None) -> None: """Every pointer leaves: nothing is hovered, and the hover targets forget where they were.""" if not self._hover_pos: return self._hover_pos.clear() self._hover_kind.clear() self._hover_targets.clear() self._hover_pending = None self._hover_dirty = False self._walk_mouse_over(root) self._cursor_dirty = True
# ------------------------------------------------------------------ cursor shape def _refresh_cursor(self, scope: Node) -> None: """Show the ``cursor_shape`` of the control the mouse or a pen last moved over. A captor keeps its own shape for the length of its drag, wherever the pointer goes. A control that names no shape shows the one the control it sits in names, and ``ARROW`` at the top. A tree where no control names a shape does no hit test for it at all. """ self._cursor_dirty = False tree = self._tree if tree is None: return shape = CursorShape.ARROW slot = self._cursor_slot if tree._cursor_shaped and slot is not None: node = self._captor_for(*slot) if node is None and slot in self._hover_pos: chain = self._hit_chain(scope, self._hover_pos[slot]) node = chain[0] if chain else None shape = _cursor_shape_at(node) if shape is not tree._own_input._cursor_shape: tree._own_input._show_cursor_shape(shape) # ------------------------------------------------------------------ hover focus
[docs] def window_focus(self, source_id: int, focused: bool) -> None: """Record whether the window *source_id* has the operating system's keyboard focus. Only that window moves focus by hover: a cursor passing over an inactive window's buttons must not steal focus inside it. """ if focused: self._unfocused_sources.discard(source_id) else: self._unfocused_sources.add(source_id) self._hover_pending = None
def _follow_hover(self, scope: Node) -> None: """Take focus for the topmost ``HOVER`` node a mouse or a pen has come to rest over. Only a change of that node counts: resting on it again, or moving within it, is not a new request, and leaving it gives focus back to nobody. A finger never focuses by hovering, and only the window the operating system has given the keyboard to takes part. """ for slot, position in self._hover_pos.items(): if self._hover_kind.get(slot) not in (PointerType.MOUSE, PointerType.PEN): continue if slot[0] in self._unfocused_sources: continue target = self._topmost_hover(scope, position) if target is self._hover_targets.get(slot): continue self._hover_targets[slot] = target if target is not None: self._hover_pending = target if self._hover_pending is not None: self._apply_hover_focus() def _topmost_hover(self, scope: Node, position) -> Node | None: """The nearest control under *position* whose ``focus_mode`` follows hover, or None.""" for control in self._hit_chain(scope, position): if control.focus_mode.follows_hover: return control return None def _apply_hover_focus(self) -> None: """Hand focus to the pending hover target once nothing forbids it; otherwise keep it pending. Hover never moves focus while a text session is open (the player is typing), while a key or a button is held (a drag, a chord, a held direction), or while a pointer is captured; the target waits and is re-checked when those end. It never moves focus from inside its own subtree, which would take the keyboard from the field the player is in to the panel around it. """ target = self._hover_pending if target is None: return tree = self._tree if target.tree is None or tree is None: self._hover_pending = None return inp = tree.input devices = inp._devices held_keys = devices.keys_pressed if self._arriving_key is not None: held_keys = held_keys - {self._arriving_key} if ( inp.text_input_rect is not None or held_keys or devices.mouse_buttons_pressed or any(isinstance(press, int) for _source, press in self._owners) ): return self._hover_pending = None focused = self._focused_control if focused is not None and focused is not target and self._is_descendant_of(focused, target): return from .focus import is_focusable if is_focusable(target): self._set_focused_control(target, FocusReason.HOVER) def _update_mouse_over_states(self, root: Node, mouse_pos, pointer: tuple[int, int] = (0, 0)): """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. The press and release paths call this directly: they are rare, and a widget deciding what a click means may read the hover it was drawn with. Motion defers to :meth:`flush_hover`. """ self._hover_dirty = False self._hover_pos[pointer] = mouse_pos self._walk_mouse_over(root) self._cursor_dirty = True def _walk_mouse_over(self, root: Node | None) -> None: """Recompute ``mouse_over`` under *root* from every pointer that hovers. A control is hovered when any pointer is over it: a cursor, a pen in the air, a finger that is down. Two windows onto one tree have two cursors and either may be on a widget, and one bool answers what a widget draws itself from; which of them it is belongs to the event that arrives, not to the highlight. """ if not root: return positions = tuple(self._hover_pos.values()) Control = _get_control() def update_recursive(node): if not node.visible: return if isinstance(node, Control): node._update_mouse_over(positions) for child in node.children: update_recursive(child) update_recursive(root) # ------------------------------------------------------------------ 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, reason: FocusReason = FocusReason.CODE): """Move keyboard focus to *control* (or nowhere), recording *reason*, and tell both sides.""" if self._focused_control is control: return old = self._focused_control self._focused_control = control self._focus_reason = reason if control is not None or old is not None else self._focus_reason if old is not None: old.queue_redraw() old.focus_exited.emit() if control is not None: control.queue_redraw() control.focus_entered.emit() _debug_log_focus(old, control) self._follow_text_input(control) @staticmethod def _follow_text_input(control) -> None: """Open or close the platform's text-input session behind the focus. This is what makes a text field usable on a phone without the game asking: focusing one raises the keyboard and focusing away puts it down. A control says it takes typing through ``wants_text_input``, and the session is given its rect so the platform can scroll it clear of the keyboard it is about to cover the screen with. Only the widgets that take typing pay anything here; for every other focus change this is one class-attribute read and a call that returns at once because there is no session open. """ from ..input import Input if control is not None and getattr(control, "wants_text_input", False): Input.begin_text_input(control.get_global_rect()) else: Input.end_text_input()