"""Named keyboard shortcuts: a binding, or a chord of two, mapped to a callback.
A shortcut is a binding like any other, ``Key.CTRL + Key.S``, and a key press
matches it by the rule every binding is read by: a binding holding a modifier
is exact, so Ctrl+S is not Ctrl+Shift+S, and ``Key.DELETE`` alone ignores
modifiers. A chord is two bindings pressed one after the other,
``(Key.CTRL + Key.K, Key.CTRL + Key.C)``.
An editor hears keys in the capture phase and hands each press here, so a
shortcut fires ahead of whatever control has focus::
mgr = ShortcutManager()
mgr.register("save", Key.CTRL + Key.S, save)
mgr.register("comment", (Key.CTRL + Key.K, Key.CTRL + Key.C), toggle_comment)
@on_input(KeyEvent, capture=True)
def _shortcut(self, event):
return (event.pressed and mgr.handle(event)) or None
A player's rebinds persist through :attr:`bindings_map` and
:meth:`load_bindings`, in the engine's one written spelling (``"ctrl+s"``).
"""
import logging
from collections.abc import Callable, Mapping, Sequence
from typing import Any
from .input.controls import DeviceButton
from .input.events import InputBinding, KeyEvent
from .input.spelling import source_from_data, source_to_data
log = logging.getLogger(__name__)
#: What a shortcut is bound to: a key, a binding holding modifiers, or a chord of two.
ShortcutKeys = DeviceButton | InputBinding | Sequence[DeviceButton | InputBinding]
def _steps(keys: object) -> tuple[InputBinding, ...]:
"""*keys* as the bindings pressed in turn: one, or two for a chord."""
items = tuple(keys) if isinstance(keys, tuple | list) else (keys,)
if not 1 <= len(items) <= 2:
raise ValueError(f"a shortcut is one binding or a chord of two, got {len(items)}: {keys!r}")
steps: list[InputBinding] = []
for item in items:
if isinstance(item, str):
raise TypeError(
f"a shortcut is a binding, not a string: write Key.CTRL + Key.S rather than {item!r} "
"(a saved spelling loads through load_bindings)"
)
binding = (
item if isinstance(item, InputBinding) else InputBinding(item) if isinstance(item, DeviceButton) else None
)
if binding is None or not isinstance(binding.control, DeviceButton):
raise TypeError(f"a shortcut step is a key or a binding such as Key.CTRL + Key.S, got {item!r}")
steps.append(binding)
return tuple(steps)
[docs]
class Shortcut:
"""One registered shortcut: its name, the bindings pressed in turn, and what it does."""
__slots__ = ("name", "steps", "callback", "description", "category")
def __init__(
self,
name: str,
steps: tuple[InputBinding, ...],
callback: Callable[[], object],
description: str = "",
category: str = "",
) -> None:
self.name = name
self.steps = steps
self.callback = callback
self.description = description
self.category = category
[docs]
@property
def label(self) -> str:
"""What a menu or a palette prints for it: ``"ctrl+s"``, a chord as ``"ctrl+k, ctrl+c"``."""
return ", ".join(step.key_combo or str(step.control) for step in self.steps)
[docs]
class ShortcutManager:
"""Registry of named shortcuts, with chords and rebinding.
A name is unique: registering one again replaces it.
"""
def __init__(self) -> None:
self._shortcuts: dict[str, Shortcut] = {}
self._pending_chord: Shortcut | None = None
self._chord_timeout: float = 1.0
self._chord_timer: float = 0.0
[docs]
def register(
self,
name: str,
keys: ShortcutKeys,
callback: Callable[[], object],
description: str = "",
category: str = "",
) -> None:
"""Register (or replace) the shortcut *name*.
Args:
name: Unique identifier, such as ``"save"``.
keys: A key (``Key.F5``), a binding (``Key.CTRL + Key.S``) or a
chord of two (``(Key.CTRL + Key.K, Key.CTRL + Key.C)``).
callback: Called with no arguments when it fires.
description: For search and display.
category: A grouping, such as ``"File"``.
"""
self._shortcuts[name] = Shortcut(name, _steps(keys), callback, description, category)
[docs]
def unregister(self, name: str) -> None:
"""Remove a shortcut by name. Raises ``KeyError`` if not found."""
del self._shortcuts[name]
[docs]
def rebind(self, name: str, keys: ShortcutKeys) -> None:
"""Point an existing shortcut at other keys, keeping its callback. ``KeyError`` if *name* is unknown."""
self._shortcuts[name].steps = _steps(keys)
[docs]
def handle(self, event: KeyEvent) -> bool:
"""Fire the shortcut *event* completes, or start the chord it begins; True when it matched.
Only a press is a shortcut, and an auto-repeat is not a new one.
"""
if not event.pressed or event.echo:
return False
if self._pending_chord is not None:
shortcut, self._pending_chord = self._pending_chord, None
self._chord_timer = 0.0
if shortcut.steps[1].matches_key(event):
shortcut.callback()
return True
return False
for shortcut in self._shortcuts.values():
if shortcut.steps[0].matches_key(event):
if len(shortcut.steps) == 2:
self._pending_chord = shortcut
self._chord_timer = 0.0
return True
shortcut.callback()
return True
return False
[docs]
def tick(self, dt: float) -> None:
"""Advance the chord timeout. Call once per frame."""
if self._pending_chord is not None:
self._chord_timer += dt
if self._chord_timer >= self._chord_timeout:
self._pending_chord = None
self._chord_timer = 0.0
[docs]
def get(self, name: str) -> Shortcut | None:
"""The shortcut registered as *name*, or None."""
return self._shortcuts.get(name)
[docs]
def search(self, query: str) -> list[Shortcut]:
"""The shortcuts whose name, description or category contain *query*, ignoring case."""
q = query.lower()
return [
sc
for sc in self._shortcuts.values()
if q in sc.name.lower() or q in sc.description.lower() or q in sc.category.lower()
]
[docs]
@property
def bindings_map(self) -> dict[str, Any]:
"""``{name: spelling}`` for every shortcut, in the engine's written form: ``"ctrl+s"``, a chord a list."""
out: dict[str, Any] = {}
for sc in self._shortcuts.values():
written = [source_to_data(step) for step in sc.steps]
out[sc.name] = written[0] if len(written) == 1 else written
return out
[docs]
def load_bindings(self, bindings: Mapping[str, Any]) -> None:
"""Rebind registered shortcuts from ``{name: spelling}``, as :attr:`bindings_map` writes it.
A name nothing registered is skipped; a spelling that is not a key
binding is logged and skipped, so one bad entry in a player's file
does not cost them the rest.
"""
for name, written in bindings.items():
if name not in self._shortcuts:
continue
parts = written if isinstance(written, list) else [written]
try:
# Validated as bindings by rebind: an axis or a composite is refused there.
parsed: tuple[Any, ...] = tuple(source_from_data(part) for part in parts)
self.rebind(name, parsed)
except TypeError, ValueError:
log.exception("Shortcut %r has an unreadable binding %r; keeping its default", name, written)