""" KeyboardManager: centralized keyboard shortcut handling. Captures all key events at the window level before any child widget. Routes to registered handlers based on keyval. """ import logging from typing import Callable import gi gi.require_version("Gtk", "4.0") from gi.repository import Gtk, Gdk log = logging.getLogger(__name__) SHIFT = Gdk.ModifierType.SHIFT_MASK CTRL = Gdk.ModifierType.CONTROL_MASK KEY_LEFT = Gdk.KEY_Left KEY_RIGHT = Gdk.KEY_Right KEY_UP = Gdk.KEY_Up KEY_DOWN = Gdk.KEY_Down KEY_RETURN = Gdk.KEY_Return KEY_KP_ENTER = Gdk.KEY_KP_Enter KEY_ESCAPE = Gdk.KEY_Escape KEY_DELETE = Gdk.KEY_Delete class KeyboardManager: """Captures key events at window level before child widgets. Usage: kb = KeyboardManager() kb.bind(KEY_LEFT, on_left) kb.bind(KEY_UP, on_up) kb.set_passthrough(lambda: isinstance(window.get_focus(), Gtk.Entry)) kb.attach(window) """ def __init__(self): self._bindings: dict[int, Callable] = {} self._passthrough: Callable[[], bool] | None = None self._passthrough_except: set[int] = set() self._window = None def bind(self, keyval: int, handler: Callable): """Register a handler for a key. Handler receives shift=bool.""" self._bindings[keyval] = handler def set_passthrough(self, check: Callable[[], bool], except_keys: set[int] | None = None): """When check() returns True, keys pass through to focused widget. Keys in except_keys are still handled even during passthrough.""" self._passthrough = check self._passthrough_except = except_keys or set() def attach(self, window): """Attach to a GTK4 window.""" self._window = window # EventControllerKey on capture phase key_ctrl = Gtk.EventControllerKey() key_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) key_ctrl.connect("key-pressed", self._on_key_pressed) window.add_controller(key_ctrl) # Reclaim focus from non-interactive widgets on click click_ctrl = Gtk.GestureClick() click_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) click_ctrl.connect("released", self._on_click) window.add_controller(click_ctrl) def _on_click(self, gesture, n_press, x, y): """After any click, if focus landed on a non-text widget, clear it.""" if not self._window: return focus = self._window.get_focus() if focus and not isinstance(focus, (Gtk.Entry, Gtk.TextView)): self._window.set_focus(None) def _on_key_pressed(self, controller, keyval, keycode, state): if self._passthrough and self._passthrough() and keyval not in self._passthrough_except: return False handler = self._bindings.get(keyval) if handler is None: return False shift = bool(state & SHIFT) try: result = handler(shift=shift) return result if result is not None else True except TypeError: result = handler() return result if result is not None else True