Source code for simvx.core.shortcuts

"""Keyboard shortcut manager for editor use.

Manages named keyboard shortcuts with modifier key support and chord combos.
Each shortcut maps a human-readable combo string (e.g. ``"Ctrl+Shift+S"`` or
``"Ctrl+K, Ctrl+C"``) to a callback that fires when the matching key event
arrives.

The manager is decoupled from the :class:`Input` singleton -- callers pass
modifier state explicitly so the module stays testable and import-free.

Example::

    mgr = ShortcutManager()
    mgr.register("save",   "Ctrl+S",       lambda: print("save"))
    mgr.register("redo",   "Ctrl+Shift+Z", lambda: print("redo"))
    mgr.register("delete", "Delete",       lambda: print("delete"))
    mgr.register("comment", "Ctrl+K, Ctrl+C", lambda: print("comment"), description="Toggle comment")

    # Called from the key-event handler:
    mgr.handle_key("s", modifiers={"ctrl"})          # prints "save"
    mgr.handle_key("z", modifiers={"ctrl", "shift"}) # prints "redo"

    # Chord: two keystrokes in sequence
    mgr.handle_key("k", modifiers={"ctrl"})           # starts chord
    mgr.handle_key("c", modifiers={"ctrl"})           # prints "comment"
"""


from __future__ import annotations

import logging
from collections.abc import Callable

log = logging.getLogger(__name__)

_MODIFIER_NAMES = frozenset({"ctrl", "shift", "alt"})


[docs] class Shortcut: """Internal representation of a registered shortcut.""" __slots__ = ( "name", "key", "modifiers", "callback", "combo", "chord_key", "chord_modifiers", "description", "category", ) def __init__( self, name: str, key: str, modifiers: frozenset[str], callback: Callable[[], object], combo: str, chord_key: str | None = None, chord_modifiers: frozenset[str] | None = None, description: str = "", category: str = "", ) -> None: self.name = name self.key = key self.modifiers = modifiers self.callback = callback self.combo = combo self.chord_key = chord_key self.chord_modifiers = chord_modifiers self.description = description self.category = category
def _parse_single_combo(combo: str) -> tuple[str, frozenset[str]]: """Parse a single combo string like ``"Ctrl+Shift+S"`` into (key, modifiers). Returns the key portion lower-cased and a frozenset of modifier names (also lower-cased). Raises :class:`ValueError` on empty or malformed combos. """ parts = [p.strip() for p in combo.split("+")] if not parts or all(p == "" for p in parts): raise ValueError(f"empty shortcut combo: {combo!r}") modifiers: set[str] = set() key: str | None = None for part in parts: lower = part.lower() if lower in _MODIFIER_NAMES: modifiers.add(lower) elif key is None: key = lower else: raise ValueError(f"shortcut combo has multiple non-modifier keys: {combo!r}") if key is None: raise ValueError(f"shortcut combo has no key (only modifiers): {combo!r}") return key, frozenset(modifiers) def _parse_combo(combo: str) -> tuple[str, frozenset[str], str | None, frozenset[str] | None]: """Parse a combo string, with optional chord support. Supports both simple combos (``"Ctrl+S"``) and chord combos (``"Ctrl+K, Ctrl+C"``). Returns ``(key, modifiers, chord_key, chord_modifiers)`` where chord_key/chord_modifiers are ``None`` for non-chord shortcuts. """ parts = [p.strip() for p in combo.split(",", 1)] key, mods = _parse_single_combo(parts[0]) if len(parts) > 1: chord_key, chord_mods = _parse_single_combo(parts[1]) return key, mods, chord_key, chord_mods return key, mods, None, None
[docs] class ShortcutManager: """Registry of named keyboard shortcuts with chord and rebinding support. Shortcuts are identified by a unique *name*. Registering a shortcut with an existing name silently replaces the previous binding. """ 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: str, callback: Callable[[], object], description: str = "", category: str = "", ) -> None: """Register (or replace) a named shortcut. Parameters ---------- name: Unique identifier for this shortcut (e.g. ``"save"``). keys: Human-readable key combo (e.g. ``"Ctrl+S"``, ``"Ctrl+K, Ctrl+C"``). callback: Zero-argument callable invoked when the shortcut fires. description: Human-readable description for search and display. category: Grouping category (e.g. ``"Editor"``, ``"File"``). """ key, modifiers, chord_key, chord_modifiers = _parse_combo(keys) self._shortcuts[name] = Shortcut( name, key, modifiers, callback, keys, chord_key=chord_key, chord_modifiers=chord_modifiers, description=description, category=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, new_keys: str) -> None: """Update an existing shortcut's key binding, keeping callback/description/category. Raises ``KeyError`` if *name* is not registered. """ sc = self._shortcuts[name] key, modifiers, chord_key, chord_modifiers = _parse_combo(new_keys) sc.key = key sc.modifiers = modifiers sc.chord_key = chord_key sc.chord_modifiers = chord_modifiers sc.combo = new_keys
[docs] def handle_key( self, key: str, modifiers: dict[str, bool] | set[str] | frozenset[str] | None = None, ) -> bool: """Dispatch callbacks for any shortcuts matching *key* + *modifiers*. Handles chord sequences: if a chord's first keystroke was already matched, this checks for the second keystroke to complete it. Returns ``True`` if a shortcut matched (or a chord started). """ active = _normalise_modifiers(modifiers) key_lower = key.lower() if self._pending_chord is not None: sc = self._pending_chord self._pending_chord = None self._chord_timer = 0.0 if sc.chord_key == key_lower and sc.chord_modifiers == active: sc.callback() return True return False for sc in self._shortcuts.values(): if sc.key == key_lower and sc.modifiers == active: if sc.chord_key is not None: self._pending_chord = sc self._chord_timer = 0.0 return True sc.callback() return True return False
[docs] def tick(self, dt: float) -> None: """Advance chord timeout timer. Call once per frame with delta time.""" 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 search(self, query: str) -> list[Shortcut]: """Return shortcuts whose name, description, or category contain *query* (case-insensitive).""" 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] def get_bindings_map(self) -> dict[str, str]: """Return ``{name: combo_string}`` for serialization.""" return {sc.name: sc.combo for sc in self._shortcuts.values()}
[docs] def load_bindings(self, bindings: dict[str, str]) -> None: """Rebind existing shortcuts from a ``{name: combo}`` mapping. Entries whose name is not currently registered are silently skipped. """ for name, keys in bindings.items(): if name in self._shortcuts: self.rebind(name, keys)
[docs] def list_shortcuts(self) -> dict[str, str]: """Return ``{name: combo_string}`` for every registered shortcut.""" return {s.name: s.combo for s in self._shortcuts.values()}
def _normalise_modifiers( modifiers: dict[str, bool] | set[str] | frozenset[str] | None, ) -> frozenset[str]: """Convert any supported modifier representation to a frozenset.""" if modifiers is None: return frozenset() if isinstance(modifiers, frozenset): return modifiers if isinstance(modifiers, set): return frozenset(m.lower() for m in modifiers) return frozenset(k.lower() for k, v in modifiers.items() if v)