From 80a01903e826cd1c7940862f838b74a250f36b5e Mon Sep 17 00:00:00 2001 From: Vitali Graf Date: Sat, 11 Apr 2026 21:16:21 +0200 Subject: [PATCH] feat: Linux-Hotkey-Record via evdev Co-Authored-By: Claude Sonnet 4.6 --- tests/test_tray.py | 107 +++++++++++++++++++++ whisper_local/tray/_hotkey_record_evdev.py | 71 ++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 whisper_local/tray/_hotkey_record_evdev.py diff --git a/tests/test_tray.py b/tests/test_tray.py index 6cc40b4..8e257da 100644 --- a/tests/test_tray.py +++ b/tests/test_tray.py @@ -268,3 +268,110 @@ class TestCreateTray: assert AppState.WAITING is not None assert AppState.RECORDING is not None assert AppState.TRANSCRIBING is not None + + +@pytest.mark.skipif(sys.platform != "linux", reason="evdev-Record nur auf Linux") +class TestRecordHotkeyEvdev: + def test_first_keydown_triggers_on_result(self): + import threading + from unittest.mock import MagicMock, patch + + fake_event = MagicMock() + fake_event.type = 1 # EV_KEY + fake_event.code = 88 # KEY_F12 + fake_event.value = 1 # Keydown + + fake_device = MagicMock() + fake_device.fd = 42 + fake_device.read.return_value = iter([fake_event]) + fake_device.close = MagicMock() + + cancel = threading.Event() + results: list[tuple[str, bool]] = [] + + def on_result(name, conflict): + results.append((name, conflict)) + + with patch( + "whisper_local.tray._hotkey_record_evdev.find_all_keyboards", + return_value=[fake_device], + ), patch( + "whisper_local.tray._hotkey_record_evdev.selectors.DefaultSelector" + ) as mock_sel_cls: + mock_sel = MagicMock() + mock_sel_cls.return_value = mock_sel + sel_key = MagicMock() + sel_key.data = fake_device + mock_sel.select.return_value = [(sel_key, None)] + from whisper_local.tray._hotkey_record_evdev import record_hotkey + record_hotkey(on_result, cancel) + + assert results == [("KEY_F12", False)] + fake_device.close.assert_called_once() + + def test_cancel_event_stops_recording(self): + import threading + from unittest.mock import MagicMock, patch + + fake_device = MagicMock() + fake_device.fd = 42 + fake_device.read.return_value = iter([]) + fake_device.close = MagicMock() + + cancel = threading.Event() + cancel.set() + + results: list[tuple[str, bool]] = [] + + with patch( + "whisper_local.tray._hotkey_record_evdev.find_all_keyboards", + return_value=[fake_device], + ), patch( + "whisper_local.tray._hotkey_record_evdev.selectors.DefaultSelector" + ) as mock_sel_cls: + mock_sel = MagicMock() + mock_sel_cls.return_value = mock_sel + mock_sel.select.return_value = [] + from whisper_local.tray._hotkey_record_evdev import record_hotkey + record_hotkey(lambda n, c: results.append((n, c)), cancel) + + assert results == [] + fake_device.close.assert_called_once() + + def test_ignores_key_up_events(self): + import threading + from unittest.mock import MagicMock, patch + + key_up = MagicMock() + key_up.type = 1 + key_up.code = 88 + key_up.value = 0 + + key_down = MagicMock() + key_down.type = 1 + key_down.code = 88 + key_down.value = 1 + + fake_device = MagicMock() + fake_device.fd = 42 + fake_device.read.return_value = iter([key_up, key_down]) + fake_device.close = MagicMock() + + cancel = threading.Event() + results: list[tuple[str, bool]] = [] + + with patch( + "whisper_local.tray._hotkey_record_evdev.find_all_keyboards", + return_value=[fake_device], + ), patch( + "whisper_local.tray._hotkey_record_evdev.selectors.DefaultSelector" + ) as mock_sel_cls: + mock_sel = MagicMock() + mock_sel_cls.return_value = mock_sel + sel_key = MagicMock() + sel_key.data = fake_device + mock_sel.select.return_value = [(sel_key, None)] + from whisper_local.tray._hotkey_record_evdev import record_hotkey + record_hotkey(lambda n, c: results.append((n, c)), cancel) + + assert results == [("KEY_F12", False)] diff --git a/whisper_local/tray/_hotkey_record_evdev.py b/whisper_local/tray/_hotkey_record_evdev.py new file mode 100644 index 0000000..bff99fb --- /dev/null +++ b/whisper_local/tray/_hotkey_record_evdev.py @@ -0,0 +1,71 @@ +"""Hotkey-Aufzeichnung via evdev (Linux).""" + +import selectors +import threading +from typing import Callable + +import evdev +from evdev import InputDevice, ecodes + + +def find_all_keyboards() -> list[InputDevice]: + """Gibt alle Input-Devices zurück, die EV_KEY-Events liefern können.""" + keyboards: list[InputDevice] = [] + for path in evdev.list_devices(): + try: + device = InputDevice(path) + except (PermissionError, OSError): + continue + capabilities = device.capabilities() + if ecodes.EV_KEY in capabilities: + keyboards.append(device) + else: + device.close() + return keyboards + + +def _keycode_to_name(code: int) -> str: + """Übersetzt evdev-Keycode zu Key-Namen. Gibt '' bei unbekanntem Code.""" + name = ecodes.KEY.get(code) + if isinstance(name, list): + return name[0] + if isinstance(name, str): + return name + return "" + + +def record_hotkey( + on_result: Callable[[str, bool], None], + cancel_event: threading.Event, +) -> None: + """Blockiert bis zum ersten Keydown oder bis cancel_event gesetzt wird. + + Ruft on_result(evdev_key_name, has_conflict) auf. has_conflict ist auf + Linux immer False — es gibt kein Äquivalent zum Win32-RegisterHotKey-Check. + """ + devices = find_all_keyboards() + if not devices: + return + + selector = selectors.DefaultSelector() + try: + for dev in devices: + selector.register(dev.fd, selectors.EVENT_READ, dev) + + captured: str | None = None + while captured is None and not cancel_event.is_set(): + for key, _mask in selector.select(timeout=0.1): + dev: InputDevice = key.data + for event in dev.read(): + if event.type == ecodes.EV_KEY and event.value == 1: + captured = _keycode_to_name(event.code) + break + if captured: + break + + if captured and not cancel_event.is_set(): + on_result(captured, False) + finally: + selector.close() + for dev in devices: + dev.close()