Source code for simvx.core.ui.navigation

"""Which input drives UI navigation: the eight ``ui_*`` actions.

The router never tests a key name or a button code of its own. It asks this
module whether what arrived is ``ui_focus_next``, ``ui_focus_prev``,
``ui_accept``, ``ui_cancel`` or one of the four directions. Those are engine
entries (:mod:`simvx.core.input.engine`), actions every tree's map has at its
bottom, so navigation is bound the way every other action is and an author can
move it:

.. code-block:: python

    input_context = {"ui_focus_next": [Key.CTRL + Key.TAB]}   # Tab is now free
    input_context = {"ui_focus_next": []}                     # no bindings: unbound

A player moves one with ``InputMap.rebind("ui_accept", Key.SPACE)``.

A key is matched by the rule every binding is read by
(:meth:`~simvx.core.input.events.InputBinding.matches_key`): the engine's Tab is
exact, so Tab and Shift+Tab are two different answers.

``ui_accept`` activates the focus owner: the router calls
:meth:`~simvx.core.ui.core.Control.activate` on it, which presses a ``Button``,
toggles a ``CheckBox``, selects a ``RadioButton`` and does nothing anywhere
else. Rebinding the action moves activation with it, and a widget that wants
the key for itself claims it first, so a focused text field keeps Enter for
submit.
"""

from __future__ import annotations

from typing import Any

from ..input.controls import DeviceAxis, DeviceButton
from ..input.engine import (
    ENGINE_CONTEXT,
    UI_ACCEPT,
    UI_ACTIONS,
    UI_CANCEL,
    UI_DOWN,
    UI_FOCUS_NEXT,
    UI_FOCUS_PREV,
    UI_LEFT,
    UI_RIGHT,
    UI_UP,
)
from ..input.events import AxisEvent, ButtonEvent, InputBinding, KeyEvent
from .enums import FocusDirection

#: Which action each direction is driven by.
UI_DIRECTIONS: dict[str, FocusDirection] = {
    UI_UP: FocusDirection.UP,
    UI_DOWN: FocusDirection.DOWN,
    UI_LEFT: FocusDirection.LEFT,
    UI_RIGHT: FocusDirection.RIGHT,
}

__all__ = [
    "UI_ACCEPT",
    "UI_CANCEL",
    "UI_DIRECTIONS",
    "UI_DOWN",
    "UI_FOCUS_NEXT",
    "UI_FOCUS_PREV",
    "UI_LEFT",
    "UI_RIGHT",
    "UI_UP",
    "ui_action_bindings",
    "ui_action_keys",
    "ui_action_matches",
    "ui_actions_for_pad_event",
]


[docs] def ui_action_bindings(action: str, input_map: Any = None) -> tuple[InputBinding, ...]: """Every binding on a navigation action in *input_map*, or in the tree in play's when it is None. With no tree at all the engine's own bindings answer. Declaring the action with no bindings disables it on every device at once. """ if input_map is None: from ..input.map import current_input_map try: input_map = current_input_map() except RuntimeError: return tuple(b for b in ENGINE_CONTEXT._actions[action].bindings) return tuple(input_map.get_bindings(action))
[docs] def ui_action_keys(action: str, input_map: Any = None) -> tuple[str, ...]: """Key combos currently bound to ``action``, defaults included.""" return tuple(combo for combo in (b.key_combo for b in ui_action_bindings(action, input_map)) if combo)
[docs] def ui_action_matches(action: str, event: KeyEvent, input_map: Any = None) -> bool: """True when the key ``event`` is bound to ``action``. The match is field by field against each binding's key and modifier flags (:meth:`~simvx.core.input.events.InputBinding.matches_key`) and it is exact, so Tab and Shift+Tab are two different answers. Handed anything but a :class:`~simvx.core.input.events.KeyEvent` this raises rather than answering False. Every route into UI navigation used to pass a combo string, and a string arriving here would silently match nothing: a dialog that stopped closing on Escape and a Tab that stopped moving focus, with no error anywhere. """ if not isinstance(event, KeyEvent): raise TypeError( f"ui_action_matches() takes the KeyEvent, not {type(event).__name__}. " "A combo string ('ctrl+s') is not an input any more: pass the event the router " "delivered, or build one with KeyEvent(key=Key.S, ctrl=True)." ) return any(b.matches_key(event) for b in ui_action_bindings(action, input_map))
[docs] def ui_actions_for_pad_event( event: ButtonEvent | AxisEvent, input_state: Any, input_map: Any = None, ) -> list[tuple[str, bool]]: """Which navigation actions a pad event opens or closes, as ``(action, pressed)``. A button carries its own edge. A stick does not: it becomes an edge only where it crosses a binding's deadzone in the direction that binding names, measured against the value the axis held before this frame, which is the same test the polling path uses. So one stick event can close ``ui_left`` and open ``ui_right`` in a single flick, and a stick pushed and held reports one press rather than one per poll. ``input_state`` is the ``_Input`` the event belongs to; it owns both the threshold test and the frame-start value, so a handler and a poll cannot drift apart. """ if isinstance(event, AxisEvent): return _axis_ui_actions(event, input_state, input_map) return _button_ui_actions(event, input_map)
def _axis_ui_actions(event: AxisEvent, input_state: Any, input_map: Any) -> list[tuple[str, bool]]: """The actions a stick movement opens or closes, by where it crosses each deadzone.""" matched: list[tuple[str, bool]] = [] forms = input_state._axis_forms(event) for action in UI_ACTIONS: for b in ui_action_bindings(action, input_map): control = b.control if not isinstance(control, DeviceAxis) or control.whole not in forms: continue if control.unit is not None and control.unit != event.device_id: continue now = input_state._axis_beyond_deadzone(b, event.value or 0.0) before = input_state._axis_beyond_deadzone(b, input_state._device_axis(b, before=True)) if now != before: matched.append((action, now)) break return matched def _button_ui_actions(event: ButtonEvent, input_map: Any) -> list[tuple[str, bool]]: """The actions a pad button opens or closes, which is its own edge.""" matched: list[tuple[str, bool]] = [] for action in UI_ACTIONS: for b in ui_action_bindings(action, input_map): control = b.control if not isinstance(control, DeviceButton) or control.on(None) is not event.button: continue if control.unit is None or control.unit == event.device_id: matched.append((action, event.pressed)) break return matched