"""The focus model: one authority for who can hold focus and who is in the tab order.
``focus_mode`` is that authority, on any node, on two independent axes:
- **Can it hold focus?** ``focus_mode.takes_focus`` (anything but ``NONE``) -- :func:`is_focusable`.
- **Is it in the tab order?** ``focus_mode.in_tab_order`` (``ALL`` or ``HOVER``) -- :func:`is_tab_stop`.
Both axes additionally require the node to be enabled and effectively visible (through the whole
ancestor chain, ``visible_in_tree``): a node the player cannot see or use is not one the keyboard
can reach. A ``CLICK`` node can therefore hold focus without ever being a Tab destination, which is
the web platform's ``tabindex="-1"``.
The search is over the tree's index of nodes whose ``focus_mode`` takes focus, kept as they enter,
leave and change mode, so Tab and a D-pad never walk the sprites and the particles between the
buttons.
:func:`next_focus` is the single traversal routine. Everything that moves focus by keyboard goes
through it: the UI router's Tab handling, ``Control.focus_next_control`` and its reverse. It walks
the scope in pre-order and resolves the successor from the focus owner's POSITION in that walk, so
an owner that is not itself a tab stop (a clicked ``Button``) still hands over to its neighbour
rather than restarting at the first control.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from .enums import FocusDirection
if TYPE_CHECKING:
from ..node import Node
__all__ = ["first_tab_stop", "focus_neighbour", "is_focusable", "is_tab_stop", "next_focus", "tab_stops"]
#: Which explicit link each direction consults before searching by geometry.
_DIRECTION_LINKS: dict[FocusDirection, str] = {
FocusDirection.UP: "focus_up",
FocusDirection.DOWN: "focus_down",
FocusDirection.LEFT: "focus_left",
FocusDirection.RIGHT: "focus_right",
}
#: How much a sideways offset counts against a candidate, relative to the
#: distance along the direction of travel. A candidate whose rect overlaps the
#: owner's on the perpendicular axis is on the same row or column and is
#: charged little for being wide; one that does not is a diagonal move and is
#: charged heavily, so a press of Down prefers the control directly below to a
#: nearer one off to the side.
_ALIGNED_COST = 0.5
_DIAGONAL_COST = 3.0
[docs]
def is_focusable(node: Node) -> bool:
"""True when ``node`` may hold keyboard focus: visible, enabled, and a ``focus_mode`` that takes it."""
return node.focus_mode.takes_focus and not getattr(node, "disabled", False) and node.visible_in_tree
[docs]
def is_tab_stop(node: Node) -> bool:
"""True when ``node`` is a Tab destination: visible, enabled, ``focus_mode`` ``ALL`` or ``HOVER``."""
return node.focus_mode.in_tab_order and not getattr(node, "disabled", False) and node.visible_in_tree
def _controls(scope_root: Node, include: Any = None) -> list[Node]:
"""The focusable nodes in ``scope_root``'s subtree in pre-order, *include* placed among them if given.
Read from the tree's index rather than walked, so the cost is the number
of focusable nodes; a scope outside any tree is walked.
"""
tree = scope_root.tree
if tree is None:
found = [node for node in scope_root.walk(include_self=True) if node.focus_mode.takes_focus]
if include is not None and include not in found and _within(include, scope_root):
found.append(include)
return found
from ..scene_tree import _input_order_key
top = tree._tree_root
members = [node for node in tree._focusables if _within(node, scope_root)]
if include is not None and include not in tree._focusables and _within(include, scope_root):
members.append(include)
keyed = [(key, node) for node in members if (key := _input_order_key(node, top)) is not None]
keyed.sort(key=lambda pair: pair[0])
return [node for _key, node in keyed]
[docs]
def tab_stops(scope_root: Node) -> list[Node]:
"""The Tab order inside ``scope_root``: its tab stops in pre-order."""
return [c for c in _controls(scope_root) if is_tab_stop(c)]
[docs]
def first_tab_stop(scope_root: Node, *, skip: Any = None) -> Node | None:
"""First tab stop inside ``scope_root`` in pre-order, ignoring ``skip``."""
for control in _controls(scope_root):
if control is not skip and is_tab_stop(control):
return control
return None
def _index_of(controls: list[Node], target: Any) -> int:
"""Position of ``target`` in ``controls`` by identity, or -1 when absent."""
for i, control in enumerate(controls):
if control is target:
return i
return -1
def _within(node: Any, scope_root: Any) -> bool:
while node is not None:
if node is scope_root:
return True
node = node.parent
return False
[docs]
def next_focus(scope_root: Node, current: Node | None, *, reverse: bool = False) -> Node | None:
"""The control Tab (or Shift+Tab, with ``reverse``) should focus next inside ``scope_root``.
``current`` is the focus owner, or ``None``. An explicit ``focus_next`` / ``focus_previous``
link on the owner wins when it points at a focusable control inside the scope; a link to a
hidden, disabled or ``NONE``-mode control falls through to the walk rather than focusing
something unreachable.
Otherwise the scope is walked in pre-order and the search starts from the owner's position in
that walk, wrapping at the ends. An owner absent from the walk (a different scope, or ``None``)
starts at the first tab stop, or the last one under ``reverse``. Returns ``None`` when the
scope holds no tab stop at all.
"""
if current is not None:
link: Node | None = getattr(current, "focus_previous" if reverse else "focus_next", None)
if link is not None and is_focusable(link) and _within(link, scope_root):
return link
controls = _controls(scope_root, include=current)
stops = [i for i, c in enumerate(controls) if is_tab_stop(c)]
if not stops:
return None
pos = _index_of(controls, current)
if pos < 0:
return controls[stops[-1] if reverse else stops[0]]
step = -1 if reverse else 1
n = len(controls)
for offset in range(1, n + 1):
candidate = controls[(pos + step * offset) % n]
if is_tab_stop(candidate):
return candidate
return None
def _travel(direction: FocusDirection, dx: float, dy: float) -> tuple[float, float]:
"""``(along, across)``: how far a candidate lies in *direction*, and to the side."""
if direction is FocusDirection.RIGHT:
return dx, dy
if direction is FocusDirection.LEFT:
return -dx, dy
if direction is FocusDirection.DOWN:
return dy, dx
return -dy, dx
def _overlaps(
direction: FocusDirection, a: tuple[float, float, float, float], b: tuple[float, float, float, float]
) -> bool:
"""Whether two rects share any extent on the axis across *direction*."""
if direction in (FocusDirection.LEFT, FocusDirection.RIGHT):
return a[1] < b[1] + b[3] and b[1] < a[1] + a[3]
return a[0] < b[0] + b[2] and b[0] < a[0] + a[2]
[docs]
def focus_neighbour(scope_root: Node, current: Node | None, direction: FocusDirection | str) -> Node | None:
"""The control a press of *direction* should focus inside ``scope_root``.
An explicit ``focus_up`` / ``focus_down`` / ``focus_left`` / ``focus_right``
link on the owner wins when it points at a focusable control inside the
scope; a link to a hidden, disabled or ``NONE``-mode control falls through
to the search rather than focusing something unreachable, exactly as
:func:`next_focus` treats its own links.
Otherwise the search is geometric, over the same destinations Tab reaches
(:func:`is_tab_stop`), using each control's ``focus_rect``. A candidate
counts only if its centre lies in *direction* at all; among those, the
nearest wins, with a sideways offset charged against it and charged much
more heavily when the two rects do not overlap on the perpendicular axis,
so Down prefers the control directly below to a nearer one off to the side.
With no focus owner the first tab stop is elected, which is what lets a
controller player enter a menu that nothing has focused yet. Nothing in
that direction returns ``None`` rather than wrapping: a wrap makes a
directional press jump across the screen, which reads as a bug.
"""
direction = FocusDirection(direction)
if current is None:
return first_tab_stop(scope_root)
link: Node | None = getattr(current, _DIRECTION_LINKS[direction], None)
if link is not None and is_focusable(link) and _within(link, scope_root):
return link
origin = current.focus_rect
if origin is None:
return first_tab_stop(scope_root, skip=current)
ox, oy = origin[0] + origin[2] / 2.0, origin[1] + origin[3] / 2.0
best: Node | None = None
best_score = 0.0
for candidate in _controls(scope_root):
if candidate is current or not is_tab_stop(candidate):
continue
rect = candidate.focus_rect
if rect is None:
continue # off screen: a direction reaches only what the player can see
along, across = _travel(direction, rect[0] + rect[2] / 2.0 - ox, rect[1] + rect[3] / 2.0 - oy)
if along <= 0.0:
continue
cost = _ALIGNED_COST if _overlaps(direction, origin, rect) else _DIAGONAL_COST
score = along + abs(across) * cost
if best is None or score < best_score:
best, best_score = candidate, score
return best
[docs]
def scope_root_for(control: Node) -> Node:
"""The traversal scope for ``control``: the capturing overlay chain it sits in, else the root.
Keyboard traversal never leaves an open capturing overlay, so a dialog's Tab order is the
dialog. Outside one (or for a control beneath one), the scope is the whole tree.
"""
tree = control.tree
if tree is not None:
scope: Node | None = tree._ui._overlay_scope()
if scope is not None and _within(control, scope):
return scope
node: Node = control
parent = node.parent
while parent is not None:
node, parent = parent, parent.parent
return node