Source code for simvx.core.shortcuts

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