diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py index 12ea5a9..0c48476 100644 --- a/tests/test_hotkey.py +++ b/tests/test_hotkey.py @@ -4,8 +4,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="evdev only available on Linux") - @pytest.fixture def listener(): @@ -13,6 +11,7 @@ def listener(): return EvdevHotkeyListener(key_name="KEY_F12") +@pytest.mark.skipif(sys.platform != "linux", reason="evdev only available on Linux") class TestHotkeyListener: def test_init_stores_key_name(self, listener): assert listener.key_name == "KEY_F12" @@ -37,3 +36,56 @@ class TestHotkeyListener: async def test_no_callback_no_error(self, listener): await listener._handle_key_event(key_down=True) await listener._handle_key_event(key_down=False) + + +class TestPynputKeyMapping: + """Tests für die Key-Name-Übersetzung von evdev zu pynput.""" + + def test_map_function_key(self): + from whisper_local.hotkey._pynput import _evdev_to_pynput_key + from pynput.keyboard import Key + assert _evdev_to_pynput_key("KEY_F12") == Key.f12 + + def test_map_f1(self): + from whisper_local.hotkey._pynput import _evdev_to_pynput_key + from pynput.keyboard import Key + assert _evdev_to_pynput_key("KEY_F1") == Key.f1 + + def test_map_unknown_key_raises(self): + from whisper_local.hotkey._pynput import _evdev_to_pynput_key + with pytest.raises(ValueError, match="Unbekannter Key-Name"): + _evdev_to_pynput_key("KEY_NONEXISTENT_999") + + +class TestPynputHotkeyListener: + def test_init_stores_key_name(self): + from whisper_local.hotkey._pynput import PynputHotkeyListener + listener = PynputHotkeyListener("KEY_F12") + assert listener.key_name == "KEY_F12" + + @pytest.mark.asyncio + async def test_handle_key_event_press(self): + from whisper_local.hotkey._pynput import PynputHotkeyListener + listener = PynputHotkeyListener("KEY_F12") + on_press = AsyncMock() + listener.on_press = on_press + + await listener._handle_key_event(key_down=True) + on_press.assert_awaited_once() + + @pytest.mark.asyncio + async def test_handle_key_event_release(self): + from whisper_local.hotkey._pynput import PynputHotkeyListener + listener = PynputHotkeyListener("KEY_F12") + on_release = AsyncMock() + listener.on_release = on_release + + await listener._handle_key_event(key_down=False) + on_release.assert_awaited_once() + + @pytest.mark.asyncio + async def test_no_callback_no_error(self): + from whisper_local.hotkey._pynput import PynputHotkeyListener + listener = PynputHotkeyListener("KEY_F12") + await listener._handle_key_event(key_down=True) + await listener._handle_key_event(key_down=False) diff --git a/whisper_local/hotkey/_pynput.py b/whisper_local/hotkey/_pynput.py new file mode 100644 index 0000000..411659a --- /dev/null +++ b/whisper_local/hotkey/_pynput.py @@ -0,0 +1,78 @@ +"""Hotkey-Listener via pynput für Push-to-Talk (Windows / Fallback).""" + +import asyncio +import logging +import re + +from pynput.keyboard import Key, Listener + +from whisper_local.hotkey import AsyncCallback + +logger = logging.getLogger(__name__) + + +def _evdev_to_pynput_key(evdev_name: str) -> Key: + """Übersetzt evdev Key-Namen (z.B. KEY_F12) ins pynput-Format.""" + match = re.match(r"^KEY_(F\d+)$", evdev_name) + if match: + attr = match.group(1).lower() + key = getattr(Key, attr, None) + if key is not None: + return key + + # Versuche direkte Zuordnung (KEY_SPACE -> space, etc.) + suffix = evdev_name.removeprefix("KEY_").lower() + key = getattr(Key, suffix, None) + if key is not None: + return key + + raise ValueError(f"Unbekannter Key-Name: {evdev_name}") + + +class PynputHotkeyListener: + def __init__(self, key_name: str = "KEY_F12"): + self.key_name = key_name + self._target_key = _evdev_to_pynput_key(key_name) + self.on_press: AsyncCallback | None = None + self.on_release: AsyncCallback | None = None + self._loop: asyncio.AbstractEventLoop | None = None + + async def _handle_key_event(self, key_down: bool) -> None: + """Ruft den passenden Callback auf.""" + if key_down and self.on_press: + await self.on_press() + elif not key_down and self.on_release: + await self.on_release() + + def _schedule_callback(self, key_down: bool) -> None: + """Bridged den Thread-Callback zu asyncio.""" + if self._loop is None: + return + self._loop.call_soon_threadsafe( + asyncio.ensure_future, + self._handle_key_event(key_down), + ) + + def _on_press(self, key) -> None: + if key == self._target_key: + logger.debug("%s gedrückt", self.key_name) + self._schedule_callback(key_down=True) + + def _on_release(self, key) -> None: + if key == self._target_key: + logger.debug("%s losgelassen", self.key_name) + self._schedule_callback(key_down=False) + + async def listen(self) -> None: + """Startet den pynput Keyboard-Listener und blockiert async.""" + self._loop = asyncio.get_running_loop() + stop_event = asyncio.Event() + + listener = Listener(on_press=self._on_press, on_release=self._on_release) + listener.start() + logger.info("Lausche auf %s via pynput", self.key_name) + + try: + await stop_event.wait() # Blockiert bis zum Programmende + finally: + listener.stop()