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