Files
mitus/cht/ui/keyboard.py
2026-04-03 04:10:16 -03:00

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