Source code for exlab_wizard.ui.keyboard

"""Keyboard-shortcut registry (Frontend Spec §3.7).

The bindings are intentionally small and central. Adding a new shortcut is
a deliberate spec change to §3.7 plus a registry entry here; bypassing the
registry to bind directly on a NiceGUI element is a code-review reject.

The registry exposes the canonical macOS and Windows / Linux key combos for
each :class:`Shortcut`. Per-shortcut handlers are looked up at runtime so
the pages don't need to import the registry's binding helper to register.
"""

from __future__ import annotations

import sys
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import StrEnum

from exlab_wizard.logging import get_logger

_log = get_logger(__name__)


[docs] class Shortcut(StrEnum): """Identifiers for the app-level shortcut set (Frontend §3.7).""" NEW_PROJECT = "new_project" NEW_RUN = "new_run" NEW_TEST_RUN = "new_test_run" OPEN_SETTINGS = "open_settings" REFRESH_TREE = "refresh_tree" OPEN_PROBLEMS = "open_problems" FOCUS_TREE_SEARCH = "focus_tree_search" WIZARD_NEXT = "wizard_next" WIZARD_CANCEL = "wizard_cancel"
[docs] @dataclass(frozen=True) class KeyCombo: """A modifier-and-key combination.""" key: str cmd: bool = False ctrl: bool = False shift: bool = False alt: bool = False
[docs] def matches(self, *, key: str, cmd: bool, ctrl: bool, shift: bool, alt: bool) -> bool: """Return True if a NiceGUI ``KeyEventArguments`` matches this combo.""" return ( self.key.lower() == key.lower() and self.cmd == cmd and self.ctrl == ctrl and self.shift == shift and self.alt == alt )
[docs] @dataclass(frozen=True) class ShortcutBinding: """One row in the §3.7 shortcut table.""" shortcut: Shortcut macos: KeyCombo other: KeyCombo description: str
# Canonical bindings (Frontend §3.7). _BINDINGS: tuple[ShortcutBinding, ...] = ( ShortcutBinding( shortcut=Shortcut.NEW_PROJECT, macos=KeyCombo(key="n", cmd=True), other=KeyCombo(key="n", ctrl=True), description="Open the New Project Wizard", ), ShortcutBinding( shortcut=Shortcut.NEW_RUN, macos=KeyCombo(key="n", cmd=True, shift=True), other=KeyCombo(key="n", ctrl=True, shift=True), description="Open the New Experimental Run Wizard", ), ShortcutBinding( shortcut=Shortcut.NEW_TEST_RUN, macos=KeyCombo(key="t", cmd=True, shift=True), other=KeyCombo(key="t", ctrl=True, shift=True), description="Open the New Test Run Wizard", ), ShortcutBinding( shortcut=Shortcut.OPEN_SETTINGS, macos=KeyCombo(key=",", cmd=True), other=KeyCombo(key=",", ctrl=True), description="Open the Settings dialog", ), ShortcutBinding( shortcut=Shortcut.REFRESH_TREE, macos=KeyCombo(key="r", cmd=True), other=KeyCombo(key="r", ctrl=True), description="Refresh the tree", ), ShortcutBinding( shortcut=Shortcut.OPEN_PROBLEMS, macos=KeyCombo(key="p", cmd=True, shift=True), other=KeyCombo(key="p", ctrl=True, shift=True), description="Switch right panel to Problems tab", ), ShortcutBinding( shortcut=Shortcut.FOCUS_TREE_SEARCH, macos=KeyCombo(key="/"), other=KeyCombo(key="/"), description="Focus the tree search box", ), ShortcutBinding( shortcut=Shortcut.WIZARD_NEXT, macos=KeyCombo(key="enter", cmd=True), other=KeyCombo(key="enter", ctrl=True), description="Advance to the next wizard step", ), ShortcutBinding( shortcut=Shortcut.WIZARD_CANCEL, macos=KeyCombo(key="escape"), other=KeyCombo(key="escape"), description="Cancel the active wizard step", ), )
[docs] @dataclass class ShortcutRegistry: """A populated registry of shortcut handlers. Pages instantiate one registry, register handlers for the actions they care about, and pass the registry to :func:`bind_global_shortcuts` to install a single keyboard listener. """ handlers: dict[Shortcut, Callable[[], None]] = field(default_factory=dict)
[docs] def register(self, shortcut: Shortcut, handler: Callable[[], None]) -> None: """Attach a handler. Only one handler per shortcut. Raises: ValueError: if a handler is already registered for ``shortcut``. """ if shortcut in self.handlers: raise ValueError( f"shortcut {shortcut.value!r} already has a handler; " "only one handler per shortcut is allowed", ) self.handlers[shortcut] = handler
[docs] def dispatch(self, shortcut: Shortcut) -> bool: """Invoke the handler for ``shortcut`` if registered. Returns ``True`` if a handler ran, ``False`` if the shortcut had no registered handler. """ handler = self.handlers.get(shortcut) if handler is None: return False try: handler() except Exception as exc: _log.exception( "shortcut_handler_failed", extra={"event": "ui.shortcut.failed", "shortcut": shortcut.value}, ) raise exc return True
[docs] def list_bindings() -> tuple[ShortcutBinding, ...]: """Return the canonical bindings table (Frontend §3.7).""" return _BINDINGS
[docs] def get_binding(shortcut: Shortcut) -> ShortcutBinding: """Return the :class:`ShortcutBinding` for the given shortcut id. Raises: KeyError: if the shortcut is not in the canonical table. """ for entry in _BINDINGS: if entry.shortcut is shortcut or entry.shortcut == shortcut: return entry raise KeyError(f"unknown shortcut {shortcut!r}")
[docs] def is_macos() -> bool: """Return ``True`` when running on macOS (used for combo selection).""" return sys.platform == "darwin"
[docs] def combo_for_current_os(shortcut: Shortcut) -> KeyCombo: """Resolve the :class:`KeyCombo` for the current OS.""" binding = get_binding(shortcut) return binding.macos if is_macos() else binding.other
[docs] def resolve( *, key: str, cmd: bool = False, ctrl: bool = False, shift: bool = False, alt: bool = False, ) -> Shortcut | None: """Find the shortcut id matching the given key event, if any.""" for entry in _BINDINGS: combo = entry.macos if is_macos() else entry.other if combo.matches(key=key, cmd=cmd, ctrl=ctrl, shift=shift, alt=alt): return entry.shortcut return None
[docs] def bind_global_shortcuts(registry: ShortcutRegistry) -> None: """Install a NiceGUI keyboard listener for the registry. NiceGUI is imported lazily so unit tests can exercise the registry surface without spinning up an app. """ from nicegui import ui def _handle(event) -> None: # type: ignore[no-untyped-def] action = event.action if not getattr(action, "keydown", False): return modifiers = getattr(event, "modifiers", None) cmd = bool(getattr(modifiers, "meta", False)) ctrl = bool(getattr(modifiers, "ctrl", False)) shift = bool(getattr(modifiers, "shift", False)) alt = bool(getattr(modifiers, "alt", False)) key = getattr(event.key, "name", "") or "" shortcut = resolve(key=key, cmd=cmd, ctrl=ctrl, shift=shift, alt=alt) if shortcut is None: return registry.dispatch(shortcut) ui.keyboard(on_key=_handle) _log.info( "global_shortcuts_bound", extra={ "event": "ui.keyboard.bound", "count": len(registry.handlers), }, )