"""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