Source code for simvx.core.ui.focus

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