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 two independent axes:

- **Can it hold focus?** ``focus_mode != FocusMode.NONE`` -- :func:`is_focusable`.
- **Is it in the tab order?** ``focus_mode == FocusMode.ALL`` -- :func:`is_tab_stop`.

Both axes additionally require the control to be enabled and effectively visible (tested through
the whole ancestor chain via ``Node._visible_in_hierarchy``, not just the control's own flag): a
control the player cannot see or use is not one the keyboard can reach. A ``CLICK`` control can
therefore hold focus without ever being a Tab destination, which is the web platform's
``tabindex="-1"``.

: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 FocusMode

if TYPE_CHECKING:
    from ..node import Node
    from .core import Control

__all__ = ["first_tab_stop", "is_focusable", "is_tab_stop", "next_focus", "tab_stops"]


[docs] def is_focusable(control: Control) -> bool: """True when ``control`` may hold keyboard focus (visible, enabled, ``focus_mode != NONE``).""" return control.focus_mode != FocusMode.NONE and not control.disabled and control._visible_in_hierarchy
[docs] def is_tab_stop(control: Control) -> bool: """True when ``control`` is a Tab destination (visible, enabled, ``focus_mode == ALL``).""" return control.focus_mode == FocusMode.ALL and not control.disabled and control._visible_in_hierarchy
def _controls(scope_root: Node) -> list[Control]: """Every ``Control`` in ``scope_root``'s subtree, in pre-order (``scope_root`` included).""" from .core import Control return [node for node in scope_root.walk(include_self=True) if isinstance(node, Control)]
[docs] def tab_stops(scope_root: Node) -> list[Control]: """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) -> Control | 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[Control], 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: Control | None, *, reverse: bool = False) -> Control | 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 = current.focus_previous if reverse else current.focus_next if link is not None and is_focusable(link) and _within(link, scope_root): return link controls = _controls(scope_root) 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
[docs] def scope_root_for(control: Control) -> 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