From ca39ba930e61dccad17602ba70b073b64b991685 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Wed, 25 Mar 2026 22:54:10 -0700 Subject: [PATCH] fix(keyboard): generalize pynput modifier alias matching across layouts (#342) * fix(keyboard): support AltGr (ISO_Level3_Shift) for Right Alt on int'l keyboards On UK/International keyboards, Right Alt (AltGr) sends ISO_Level3_Shift (keysym 0xfe03) instead of Alt_R (keysym 0xffea). The pynput backend was only checking for keyboard.Key.alt_r, missing the alt_gr key that pynput uses for ISO_Level3_Shift. Changes: - Add keyboard.Key.alt_gr to MODIFIER_NORMALIZE_MAP (normalizes to alt) - Include alt_gr in the "alt" and "right_alt" key variants - Update side-specific matching to check key variants instead of exact key comparison Fixes #322 * fix(keyboard): generalize pynput modifier alias matching * test(keyboard): make variant tests independent of pynput install --- .../ui/keyboard_backends/pynput_backend.py | 109 +++++++++++------- tests/test_pynput_backend_ext.py | 88 ++++++++------ 2 files changed, 120 insertions(+), 77 deletions(-) diff --git a/src/vocalinux/ui/keyboard_backends/pynput_backend.py b/src/vocalinux/ui/keyboard_backends/pynput_backend.py index 9ce221f..2b835e7 100644 --- a/src/vocalinux/ui/keyboard_backends/pynput_backend.py +++ b/src/vocalinux/ui/keyboard_backends/pynput_backend.py @@ -24,20 +24,57 @@ from .base import DEFAULT_SHORTCUT, DEFAULT_SHORTCUT_MODE, KeyboardBackend logger = logging.getLogger(__name__) +def _optional_key(key_name: str): + if not PYNPUT_AVAILABLE: + return None + return getattr(keyboard.Key, key_name, None) + + +def _key_set(*key_names: str) -> set: + keys = set() + for key_name in key_names: + key = _optional_key(key_name) + if key is not None: + keys.add(key) + return keys + + +def _primary_key(*key_names: str): + for key_name in key_names: + key = _optional_key(key_name) + if key is not None: + return key + return None + + MODIFIER_KEY_MAP = {} +MODIFIER_KEY_VARIANTS = {} MODIFIER_NORMALIZE_MAP = {} if PYNPUT_AVAILABLE: MODIFIER_KEY_MAP = { - "ctrl": keyboard.Key.ctrl, - "alt": keyboard.Key.alt, - "shift": keyboard.Key.shift, - "super": keyboard.Key.cmd, - "left_ctrl": keyboard.Key.ctrl_l, - "left_alt": keyboard.Key.alt_l, - "left_shift": keyboard.Key.shift_l, - "right_ctrl": keyboard.Key.ctrl_r, - "right_alt": keyboard.Key.alt_r, - "right_shift": keyboard.Key.shift_r, + "ctrl": _primary_key("ctrl", "ctrl_l", "ctrl_r"), + "alt": _primary_key("alt", "alt_l", "alt_r"), + "shift": _primary_key("shift", "shift_l", "shift_r"), + "super": _primary_key("cmd", "cmd_l", "cmd_r"), + "left_ctrl": _primary_key("ctrl_l"), + "left_alt": _primary_key("alt_l"), + "left_shift": _primary_key("shift_l"), + "right_ctrl": _primary_key("ctrl_r"), + "right_alt": _primary_key("alt_r", "alt_gr"), + "right_shift": _primary_key("shift_r"), + } + + MODIFIER_KEY_VARIANTS = { + "ctrl": _key_set("ctrl", "ctrl_l", "ctrl_r"), + "alt": _key_set("alt", "alt_l", "alt_r", "alt_gr"), + "shift": _key_set("shift", "shift_l", "shift_r"), + "super": _key_set("cmd", "cmd_l", "cmd_r"), + "left_ctrl": _key_set("ctrl_l"), + "left_alt": _key_set("alt_l"), + "left_shift": _key_set("shift_l"), + "right_ctrl": _key_set("ctrl_r"), + "right_alt": _key_set("alt_r", "alt_gr"), + "right_shift": _key_set("shift_r"), } MODIFIER_NORMALIZE_MAP = { @@ -45,6 +82,7 @@ if PYNPUT_AVAILABLE: keyboard.Key.ctrl_r: keyboard.Key.ctrl, keyboard.Key.alt_l: keyboard.Key.alt, keyboard.Key.alt_r: keyboard.Key.alt, + keyboard.Key.alt_gr: keyboard.Key.alt, keyboard.Key.shift_l: keyboard.Key.shift, keyboard.Key.shift_r: keyboard.Key.shift, keyboard.Key.cmd_l: keyboard.Key.cmd, @@ -82,6 +120,18 @@ class PynputKeyboardBackend(KeyboardBackend): """Get the pynput Key object for the configured modifier.""" return MODIFIER_KEY_MAP.get(self._modifier_key) + def _matches_configured_modifier(self, key) -> bool: + key_variants = self._get_key_variants(self._modifier_key) + if key_variants: + return key in key_variants + + target_key = self._get_target_key() + if target_key is None: + return False + + normalized_key = self._normalize_modifier_key(key) + return normalized_key == target_key + def is_available(self) -> bool: """Check if pynput is available.""" return PYNPUT_AVAILABLE @@ -151,17 +201,12 @@ class PynputKeyboardBackend(KeyboardBackend): def _on_press(self, key) -> None: """Handle key press events.""" try: - target_key = self._get_target_key() - is_side_specific = self._modifier_key.startswith(("left_", "right_")) - - if is_side_specific: - matched = key == target_key - else: - normalized_key = self._normalize_modifier_key(key) - matched = normalized_key == target_key + matched = self._matches_configured_modifier(key) if matched: current_time = time.time() + normalized_key = self._normalize_modifier_key(key) + self.current_keys.add(normalized_key) if self._mode == "toggle": if ( @@ -178,11 +223,6 @@ class PynputKeyboardBackend(KeyboardBackend): threading.Thread(target=self.key_press_callback, daemon=True).start() self.last_key_press_time = current_time - - if target_key and key in self._get_key_variants(self._modifier_key): - normalized_key = self._normalize_modifier_key(key) - self.current_keys.add(normalized_key) - except Exception as e: logger.error(f"Error in pynput key press handling: {e}") @@ -190,15 +230,9 @@ class PynputKeyboardBackend(KeyboardBackend): """Handle key release events.""" try: normalized_key = self._normalize_modifier_key(key) - target_key = self._get_target_key() - is_side_specific = self._modifier_key.startswith(("left_", "right_")) self.current_keys.discard(normalized_key) - - if is_side_specific: - matched = key == target_key - else: - matched = normalized_key == target_key + matched = self._matches_configured_modifier(key) if self._mode == "push_to_talk" and matched: if self.key_release_callback is not None: @@ -216,20 +250,7 @@ class PynputKeyboardBackend(KeyboardBackend): """Get all key variants for a modifier name.""" if not PYNPUT_AVAILABLE: return set() - - variants = { - "ctrl": {keyboard.Key.ctrl, keyboard.Key.ctrl_l, keyboard.Key.ctrl_r}, - "alt": {keyboard.Key.alt, keyboard.Key.alt_l, keyboard.Key.alt_r}, - "shift": {keyboard.Key.shift, keyboard.Key.shift_l, keyboard.Key.shift_r}, - "super": {keyboard.Key.cmd, keyboard.Key.cmd_l, keyboard.Key.cmd_r}, - "left_ctrl": {keyboard.Key.ctrl_l}, - "left_alt": {keyboard.Key.alt_l}, - "left_shift": {keyboard.Key.shift_l}, - "right_ctrl": {keyboard.Key.ctrl_r}, - "right_alt": {keyboard.Key.alt_r}, - "right_shift": {keyboard.Key.shift_r}, - } - return variants.get(modifier_name, set()) + return MODIFIER_KEY_VARIANTS.get(modifier_name, set()) # Export availability diff --git a/tests/test_pynput_backend_ext.py b/tests/test_pynput_backend_ext.py index 54e1936..a130ae7 100644 --- a/tests/test_pynput_backend_ext.py +++ b/tests/test_pynput_backend_ext.py @@ -16,10 +16,7 @@ from unittest.mock import MagicMock, Mock, PropertyMock, patch import pytest -from vocalinux.ui.keyboard_backends.pynput_backend import ( - PYNPUT_AVAILABLE, - PynputKeyboardBackend, -) +from vocalinux.ui.keyboard_backends.pynput_backend import PYNPUT_AVAILABLE, PynputKeyboardBackend # Create mock pynput objects if pynput is not available @@ -32,6 +29,7 @@ class MockKey: alt = MagicMock(name="alt") alt_l = MagicMock(name="alt_l") alt_r = MagicMock(name="alt_r") + alt_gr = MagicMock(name="alt_gr") shift = MagicMock(name="shift") shift_l = MagicMock(name="shift_l") shift_r = MagicMock(name="shift_r") @@ -483,33 +481,32 @@ class TestPynputKeyboardBackendGetKeyVariants: assert result == set() @patch("vocalinux.ui.keyboard_backends.pynput_backend.PYNPUT_AVAILABLE", True) - @patch("vocalinux.ui.keyboard_backends.pynput_backend.keyboard") - def test_get_key_variants_ctrl(self, mock_keyboard): + @patch("vocalinux.ui.keyboard_backends.pynput_backend.MODIFIER_KEY_VARIANTS") + def test_get_key_variants_ctrl(self, mock_variants): """Test getting variants for ctrl modifier.""" - # Setup mock keyboard module with Key attributes - mock_keyboard.Key = MagicMock() - mock_keyboard.Key.ctrl = MagicMock() - mock_keyboard.Key.ctrl_l = MagicMock() - mock_keyboard.Key.ctrl_r = MagicMock() + ctrl = MagicMock(name="ctrl") + ctrl_l = MagicMock(name="ctrl_l") + ctrl_r = MagicMock(name="ctrl_r") + mock_variants.get.return_value = {ctrl, ctrl_l, ctrl_r} backend = PynputKeyboardBackend() result = backend._get_key_variants("ctrl") assert isinstance(result, set) - assert len(result) >= 1 + assert result == {ctrl, ctrl_l, ctrl_r} @patch("vocalinux.ui.keyboard_backends.pynput_backend.PYNPUT_AVAILABLE", True) - @patch("vocalinux.ui.keyboard_backends.pynput_backend.keyboard") - def test_get_key_variants_left_ctrl(self, mock_keyboard): + @patch("vocalinux.ui.keyboard_backends.pynput_backend.MODIFIER_KEY_VARIANTS") + def test_get_key_variants_left_ctrl(self, mock_variants): """Test getting variants for left_ctrl modifier.""" - mock_keyboard.Key = MagicMock() - mock_keyboard.Key.ctrl_l = MagicMock() + ctrl_l = MagicMock(name="ctrl_l") + mock_variants.get.return_value = {ctrl_l} backend = PynputKeyboardBackend() result = backend._get_key_variants("left_ctrl") assert isinstance(result, set) - assert len(result) >= 1 + assert result == {ctrl_l} @patch("vocalinux.ui.keyboard_backends.pynput_backend.PYNPUT_AVAILABLE", True) @patch("vocalinux.ui.keyboard_backends.pynput_backend.keyboard") @@ -523,23 +520,18 @@ class TestPynputKeyboardBackendGetKeyVariants: assert result == set() @patch("vocalinux.ui.keyboard_backends.pynput_backend.PYNPUT_AVAILABLE", True) - @patch("vocalinux.ui.keyboard_backends.pynput_backend.keyboard") - def test_get_key_variants_all_modifiers(self, mock_keyboard): + @patch("vocalinux.ui.keyboard_backends.pynput_backend.MODIFIER_KEY_VARIANTS") + def test_get_key_variants_all_modifiers(self, mock_variants): """Test getting variants for all modifier names.""" - # Setup mock keyboard module with all needed Key attributes - mock_keyboard.Key = MagicMock() - mock_keyboard.Key.ctrl = MagicMock() - mock_keyboard.Key.ctrl_l = MagicMock() - mock_keyboard.Key.ctrl_r = MagicMock() - mock_keyboard.Key.alt = MagicMock() - mock_keyboard.Key.alt_l = MagicMock() - mock_keyboard.Key.alt_r = MagicMock() - mock_keyboard.Key.shift = MagicMock() - mock_keyboard.Key.shift_l = MagicMock() - mock_keyboard.Key.shift_r = MagicMock() - mock_keyboard.Key.cmd = MagicMock() - mock_keyboard.Key.cmd_l = MagicMock() - mock_keyboard.Key.cmd_r = MagicMock() + mock_variants.get.side_effect = lambda modifier, default=None: { + "ctrl": {MagicMock(name="ctrl")}, + "alt": {MagicMock(name="alt")}, + "shift": {MagicMock(name="shift")}, + "super": {MagicMock(name="super")}, + "left_ctrl": {MagicMock(name="left_ctrl")}, + "right_alt": {MagicMock(name="right_alt")}, + "left_shift": {MagicMock(name="left_shift")}, + }.get(modifier, default if default is not None else set()) backend = PynputKeyboardBackend() @@ -547,3 +539,33 @@ class TestPynputKeyboardBackendGetKeyVariants: for modifier in modifiers: result = backend._get_key_variants(modifier) assert isinstance(result, set) + + +class TestPynputKeyboardBackendModifierMatching: + @patch("vocalinux.ui.keyboard_backends.pynput_backend.PYNPUT_AVAILABLE", True) + @patch("vocalinux.ui.keyboard_backends.pynput_backend.MODIFIER_KEY_VARIANTS") + def test_on_press_push_to_talk_triggers_with_alias_variant(self, mock_variants): + backend = PynputKeyboardBackend(shortcut="right_alt+right_alt", mode="push_to_talk") + callback = MagicMock() + backend.register_press_callback(callback) + mock_variants.get.return_value = {MockKey.alt_r, MockKey.alt_gr} + + backend._on_press(MockKey.alt_gr) + time.sleep(0.1) + + assert callback.called + + @patch("vocalinux.ui.keyboard_backends.pynput_backend.PYNPUT_AVAILABLE", True) + @patch("vocalinux.ui.keyboard_backends.pynput_backend.MODIFIER_KEY_VARIANTS") + def test_on_press_generic_uses_normalized_fallback(self, mock_variants): + backend = PynputKeyboardBackend(shortcut="alt+alt", mode="push_to_talk") + callback = MagicMock() + backend.register_press_callback(callback) + mock_variants.get.return_value = set() + backend._get_target_key = MagicMock(return_value=MockKey.alt) + backend._normalize_modifier_key = MagicMock(return_value=MockKey.alt) + + backend._on_press(MockKey.alt_r) + time.sleep(0.1) + + assert callback.called