"""Declarative keyboard routing; no input polling or animation ownership."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum
from typing import Literal
from .grid import _glyph
KeyName = Literal[
"space",
"enter",
"escape",
"tab",
"shift+tab",
"backspace",
"delete",
"insert",
"home",
"end",
"left",
"right",
"up",
"down",
"page_up",
"page_down",
"ctrl+c",
"f1",
"f2",
"f3",
"f4",
"f5",
"f6",
"f7",
"f8",
"f9",
"f10",
"f11",
"f12",
]
FocusDirection = Literal["next", "previous"]
_CODES: dict[str, int] = {
"space": 32,
"enter": 10,
"escape": -1,
"tab": -301,
"shift+tab": -302,
"backspace": -300,
"delete": -102,
"insert": -101,
"home": -200,
"end": -201,
"left": -203,
"right": -205,
"up": -204,
"down": -206,
"page_up": -207,
"page_down": -208,
"ctrl+c": 3,
**{f"f{i}": -1 - i for i in range(1, 13)},
}
def _event(code: int) -> int:
if type(code) is not int:
raise TypeError("key events must be integers")
return {27: -1, 13: 10, 9: -301, 11: -301, 8: -300, 127: -300}.get(code, code)
def _text(value: str) -> None:
if not isinstance(value, str):
raise TypeError("names and descriptions must be strings")
for char in value:
_glyph(char)
[docs]
class EventResult(Enum):
"""Dispatch result; use handle_event with Player, never enum truthiness."""
UNHANDLED = "unhandled"
HANDLED = "handled"
QUIT = "quit"
def __bool__(self) -> bool:
raise TypeError("Compare EventResult explicitly; pass handle_event to Player")
[docs]
@dataclass(frozen=True)
class BindingHint:
"""A displayable key/description pair for a help label or status bar."""
key: str
description: str
[docs]
class KeyBinding:
"""Bind a Literal-typed named key to a zero-argument action.
Use character() for a single printable character, e.g. 'q' or '+'. Actions
return None; any matched binding consumes the event. quit=True requests exit
after its optional action. Empty descriptions omit aliases from help text.
Construction never calls actions or opens a terminal.
"""
[docs]
def __init__(
self,
key: KeyName,
action: Callable[[], None] | None = None,
*,
description: str = "",
quit: bool = False,
) -> None:
if key not in _CODES:
raise ValueError(
"Unknown named key; use KeyBinding.character for text keys"
)
self._configure(_CODES[key], key, action, description, quit)
def _configure(
self,
code: int,
key: str,
action: Callable[[], None] | None,
description: str,
quit: bool,
) -> None:
_text(description)
if type(quit) is not bool:
raise TypeError("quit must be a bool")
if action is not None and not callable(action):
raise TypeError("action must be callable or None")
if action is None and not quit:
raise ValueError("A binding needs an action or quit=True")
self._code, self._key = code, key
self._action, self._description, self._quit = action, description, quit
[docs]
@classmethod
def character(
cls,
character: str,
action: Callable[[], None] | None = None,
*,
description: str = "",
quit: bool = False,
) -> KeyBinding:
"""Bind one printable single-cell character; matching is case-sensitive."""
if not isinstance(character, str) or len(character) != 1:
raise ValueError("character must be one printable single-cell character")
_glyph(character)
binding = cls.__new__(cls)
binding._configure(ord(character), character, action, description, quit)
return binding
@property
def key(self) -> str:
return self._key
@property
def description(self) -> str:
return self._description
def _invoke(self) -> EventResult:
if self._action is not None and self._action() is not None:
raise TypeError("Binding actions must return None; use quit=True to exit")
return EventResult.QUIT if self._quit else EventResult.HANDLED
[docs]
class KeyBindings:
"""An ordered, immutable set of bindings; duplicate normalized keys are errors.
dispatch returns EventResult for composition. handle_event adapts to Player's
bool contract (True only for quit). No default keys are installed here.
"""
[docs]
def __init__(self, *bindings: KeyBinding) -> None:
if any(not isinstance(binding, KeyBinding) for binding in bindings):
raise TypeError("KeyBindings requires KeyBinding instances")
codes = [binding._code for binding in bindings]
if len(set(codes)) != len(codes):
raise ValueError("Duplicate key binding")
self._bindings = bindings
self._lookup = dict(zip(codes, bindings))
@property
def bindings(self) -> tuple[KeyBinding, ...]:
return self._bindings
[docs]
def hints(self) -> tuple[BindingHint, ...]:
"""Descriptions in declaration order, omitting undescribed aliases."""
return tuple(
BindingHint(b.key, b.description) for b in self._bindings if b.description
)
[docs]
def help_text(self, separator: str = " | ") -> str:
"""Format declarative hints; never invoke actions."""
return separator.join(f"{hint.key} {hint.description}" for hint in self.hints())
[docs]
def dispatch(self, event: int) -> EventResult:
binding = self._lookup.get(_event(event))
return binding._invoke() if binding is not None else EventResult.UNHANDLED
[docs]
def handle_event(self, event: int) -> bool:
"""Player-compatible handler; handled actions never accidentally quit."""
return self.dispatch(event) is EventResult.QUIT
[docs]
class FocusTarget:
"""Bindings and focus state for one Panel, registered in one FocusGroup.
A target becomes eligible when its panel starts and is visible. enabled=False
excludes it without pausing its animation. The group borrows targets; neither
targets nor groups start/close content. Never attach a target to two panels.
"""
[docs]
def __init__(
self, name: str, bindings: KeyBindings, *, enabled: bool = True
) -> None:
_text(name)
if not name:
raise ValueError("Focus target names must be nonempty")
if not isinstance(bindings, KeyBindings):
raise TypeError("bindings must be KeyBindings")
if type(enabled) is not bool:
raise TypeError("enabled must be a bool")
self._name, self._bindings, self._enabled = name, bindings, enabled
self._active = self._focused = False
self._owner: object | None = None
self._group: FocusGroup | None = None
@property
def name(self) -> str:
return self._name
@property
def bindings(self) -> KeyBindings:
return self._bindings
@property
def focused(self) -> bool:
"""Read-only selection state; reading/drawing never changes focus."""
return self._focused
@property
def available(self) -> bool:
return self._active and self._enabled
@property
def enabled(self) -> bool:
return self._enabled
@enabled.setter
def enabled(self, value: bool) -> None:
if type(value) is not bool:
raise TypeError("enabled must be a bool")
self._enabled = value
self._refresh()
def _refresh(self) -> None:
if self._group is not None:
self._group._reconcile()
def _attach(self, owner: object) -> None:
if self._owner is not None:
raise ValueError("A FocusTarget can belong to only one panel")
self._owner = owner
def _activate(self, active: bool) -> None:
self._active = active
self._refresh()
def _detach(self) -> None:
self._owner = None
self._activate(False)
[docs]
class FocusGroup:
"""Route navigation, then focused bindings, then application bindings.
Pass handle_event to Player. Tab/Shift+Tab are reserved navigation by default;
override next_key/previous_key with typed key names. Focus wraps by default.
Unhandled q/Q, Escape and Ctrl+C quit unless global_bindings is supplied.
Local bindings can consume those keys first. Hidden/disabled/closed panels
are skipped. Showing a panel does not steal focus from an eligible selection.
No terminal polling, simulation advancement, or child ownership occurs here.
"""
[docs]
def __init__(
self,
*targets: FocusTarget,
global_bindings: KeyBindings | None = None,
next_key: KeyName = "tab",
previous_key: KeyName = "shift+tab",
wrap: bool = True,
) -> None:
if any(not isinstance(target, FocusTarget) for target in targets):
raise TypeError("FocusGroup requires FocusTarget instances")
if len({t.name for t in targets}) != len(targets):
raise ValueError("Focus target names must be unique")
if any(t._group is not None for t in targets):
raise ValueError("A FocusTarget can belong to only one group")
if next_key not in _CODES or previous_key not in _CODES:
raise ValueError("Navigation requires named keys")
if next_key == previous_key:
raise ValueError("Navigation keys must differ")
if type(wrap) is not bool:
raise TypeError("wrap must be a bool")
if global_bindings is not None and not isinstance(global_bindings, KeyBindings):
raise TypeError("global_bindings must be KeyBindings or None")
self._targets, self._wrap = targets, wrap
self._next, self._previous = _CODES[next_key], _CODES[previous_key]
self._global = (
global_bindings
if global_bindings is not None
else KeyBindings(
KeyBinding.character("q", quit=True, description="quit"),
KeyBinding.character("Q", quit=True),
KeyBinding("escape", quit=True),
KeyBinding("ctrl+c", quit=True),
)
)
self._focused: FocusTarget | None = None
for target in targets:
target._group = self
self._reconcile()
@property
def focused(self) -> FocusTarget | None:
return self._focused
@property
def targets(self) -> tuple[FocusTarget, ...]:
return self._targets
def _select(self, target: FocusTarget | None) -> None:
if self._focused is not None:
self._focused._focused = False
self._focused = target
if target is not None:
target._focused = True
def _reconcile(self) -> None:
if self._focused is not None and self._focused.available:
return
start = (
self._targets.index(self._focused) + 1 if self._focused is not None else 0
)
ordered = self._targets[start:] + self._targets[:start]
self._select(next((t for t in ordered if t.available), None))
[docs]
def focus(self, name: str) -> bool:
"""Select a named eligible target; False if hidden/disabled, KeyError if unknown."""
for target in self._targets:
if target.name == name:
if target.available:
self._select(target)
return True
return False
raise KeyError(name)
[docs]
def move(self, direction: FocusDirection = "next") -> None:
"""Move in declaration order, skipping unavailable targets."""
if direction not in ("next", "previous"):
raise ValueError("direction must be next or previous")
available = [target for target in self._targets if target.available]
if not available:
return
index = available.index(self._focused) if self._focused in available else 0
index += 1 if direction == "next" else -1
index = (
index % len(available)
if self._wrap
else max(0, min(len(available) - 1, index))
)
self._select(available[index])
[docs]
def help_text(self) -> str:
"""Current target's hints for an update-time StatusBar callback."""
return self._focused.bindings.help_text() if self._focused is not None else ""
[docs]
def dispatch(self, event: int) -> EventResult:
code = _event(event)
if code in (self._next, self._previous):
self.move("next" if code == self._next else "previous")
return EventResult.HANDLED
if self._focused is not None:
result = self._focused.bindings.dispatch(code)
if result is not EventResult.UNHANDLED:
return result
return self._global.dispatch(code)
[docs]
def handle_event(self, event: int) -> bool:
"""Return True only for quit, matching Player's event_handler contract."""
return self.dispatch(event) is EventResult.QUIT
[docs]
def _set_visible(animation: object, visible: bool) -> None:
"""Propagate layout visibility without input polling or lifecycle changes."""
notify = getattr(animation, "set_visible", None)
if notify is not None:
notify(visible)