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