96 lines
3.1 KiB
Python
96 lines
3.1 KiB
Python
"""
|
|
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
|