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
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()