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
This commit is contained in:
Jatin K Malik
2026-03-25 22:54:10 -07:00
committed by GitHub
parent 929f890b70
commit ca39ba930e
2 changed files with 120 additions and 77 deletions
@@ -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
+55 -33
View File
@@ -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