diff --git a/tests/test_tray.py b/tests/test_tray.py index 7d48fcf..6cc40b4 100644 --- a/tests/test_tray.py +++ b/tests/test_tray.py @@ -111,7 +111,7 @@ class TestApplySystemTheme: class TestCheckHotkeyConflict: def test_returns_false_when_key_is_free(self): from unittest.mock import patch, MagicMock - from whisper_local.tray._settings import check_hotkey_conflict + from whisper_local.tray._hotkey_record_pynput import check_hotkey_conflict mock_user32 = MagicMock() mock_user32.RegisterHotKey.return_value = 1 # Erfolg @@ -123,7 +123,7 @@ class TestCheckHotkeyConflict: def test_returns_true_when_key_is_taken(self): from unittest.mock import patch, MagicMock - from whisper_local.tray._settings import check_hotkey_conflict + from whisper_local.tray._hotkey_record_pynput import check_hotkey_conflict mock_user32 = MagicMock() mock_user32.RegisterHotKey.return_value = 0 # Belegt @@ -134,7 +134,7 @@ class TestCheckHotkeyConflict: mock_user32.UnregisterHotKey.assert_not_called() def test_returns_false_for_unknown_key(self): - from whisper_local.tray._settings import check_hotkey_conflict + from whisper_local.tray._hotkey_record_pynput import check_hotkey_conflict result = check_hotkey_conflict("KEY_NONEXISTENT_999") assert result is False @@ -165,22 +165,22 @@ class TestListMicrophones: class TestPynputToEvdevKey: def test_function_key(self): from pynput.keyboard import Key - from whisper_local.tray._settings import pynput_to_evdev_key + from whisper_local.tray._hotkey_record_pynput import pynput_to_evdev_key assert pynput_to_evdev_key(Key.f12) == "KEY_F12" def test_space_key(self): from pynput.keyboard import Key - from whisper_local.tray._settings import pynput_to_evdev_key + from whisper_local.tray._hotkey_record_pynput import pynput_to_evdev_key assert pynput_to_evdev_key(Key.space) == "KEY_SPACE" def test_char_key(self): from pynput.keyboard import KeyCode - from whisper_local.tray._settings import pynput_to_evdev_key + from whisper_local.tray._hotkey_record_pynput import pynput_to_evdev_key key = KeyCode.from_char("a") assert pynput_to_evdev_key(key) == "KEY_A" def test_unknown_returns_empty(self): - from whisper_local.tray._settings import pynput_to_evdev_key + from whisper_local.tray._hotkey_record_pynput import pynput_to_evdev_key assert pynput_to_evdev_key(None) == "" diff --git a/whisper_local/tray/_hotkey_record_pynput.py b/whisper_local/tray/_hotkey_record_pynput.py new file mode 100644 index 0000000..4a1174e --- /dev/null +++ b/whisper_local/tray/_hotkey_record_pynput.py @@ -0,0 +1,70 @@ +"""Hotkey-Aufzeichnung und Konflikt-Erkennung für Windows (pynput + Win32).""" + +import ctypes +import threading +from typing import Callable + + +def pynput_to_evdev_key(key) -> str: + """Konvertiert pynput-Key zu evdev-Key-Namen (z.B. Key.f12 → 'KEY_F12').""" + from pynput.keyboard import Key, KeyCode + + if isinstance(key, Key): + return f"KEY_{key.name.upper()}" + if isinstance(key, KeyCode) and key.char: + return f"KEY_{key.char.upper()}" + return "" + + +def check_hotkey_conflict(evdev_name: str) -> bool: + """Gibt True zurück wenn die Taste per Win32-RegisterHotKey belegt ist.""" + from whisper_local.hotkey._pynput import _evdev_to_pynput_key + from pynput.keyboard import Key + + try: + pynput_key = _evdev_to_pynput_key(evdev_name) + except ValueError: + return False + + if not isinstance(pynput_key, Key): + return False + + vk = getattr(pynput_key.value, "vk", None) + if vk is None: + return False + + HOTKEY_ID = 0x7FFF + user32 = ctypes.windll.user32 + if user32.RegisterHotKey(None, HOTKEY_ID, 0, vk): + user32.UnregisterHotKey(None, HOTKEY_ID) + return False + return True + + +def record_hotkey( + on_result: Callable[[str, bool], None], + cancel_event: threading.Event, +) -> None: + """Blockiert bis der nächste Keydown kommt oder cancel_event gesetzt wird. + + Ruft on_result(evdev_key_name, has_conflict) auf. + """ + from pynput.keyboard import Listener + + captured: list[str] = [] + + def on_press(key): + evdev = pynput_to_evdev_key(key) + if evdev: + captured.append(evdev) + return False # Listener stoppen + + with Listener(on_press=on_press) as lst: + while lst.running and not cancel_event.is_set(): + lst.join(timeout=0.1) + if lst.running: + lst.stop() + + if captured and not cancel_event.is_set(): + evdev = captured[0] + on_result(evdev, check_hotkey_conflict(evdev)) diff --git a/whisper_local/tray/_settings.py b/whisper_local/tray/_settings.py index bbe3608..b16714c 100644 --- a/whisper_local/tray/_settings.py +++ b/whisper_local/tray/_settings.py @@ -1,8 +1,8 @@ -"""Einstellungs-Dialog für whisper-local (Windows).""" +"""Einstellungs-Dialog für whisper-local (cross-platform).""" from __future__ import annotations -import ctypes +import sys import threading from typing import Callable @@ -12,42 +12,6 @@ from whisper_local.config import Config, save_config from whisper_local.tray._theme import apply_system_theme -def pynput_to_evdev_key(key) -> str: - """Konvertiert pynput-Key zu evdev-Key-Namen (z.B. Key.f12 → 'KEY_F12').""" - from pynput.keyboard import Key, KeyCode - - if isinstance(key, Key): - return f"KEY_{key.name.upper()}" - if isinstance(key, KeyCode) and key.char: - return f"KEY_{key.char.upper()}" - return "" - - -def check_hotkey_conflict(evdev_name: str) -> bool: - """Gibt True zurück wenn die Taste per Win32-RegisterHotKey belegt ist.""" - from whisper_local.hotkey._pynput import _evdev_to_pynput_key - from pynput.keyboard import Key - - try: - pynput_key = _evdev_to_pynput_key(evdev_name) - except ValueError: - return False - - if not isinstance(pynput_key, Key): - return False - - vk = getattr(pynput_key.value, "vk", None) - if vk is None: - return False - - HOTKEY_ID = 0x7FFF - user32 = ctypes.windll.user32 - if user32.RegisterHotKey(None, HOTKEY_ID, 0, vk): - user32.UnregisterHotKey(None, HOTKEY_ID) - return False - return True - - def list_microphones() -> list[tuple[str, int]]: """Gibt Liste aller Eingabegeräte als (name, index) zurück.""" devices = sd.query_devices() @@ -58,12 +22,22 @@ def list_microphones() -> list[tuple[str, int]]: ] +def _get_record_hotkey(): + """Wählt das plattformspezifische record_hotkey-Callable.""" + if sys.platform == "win32": + from whisper_local.tray._hotkey_record_pynput import record_hotkey + return record_hotkey + from whisper_local.tray._hotkey_record_evdev import record_hotkey + return record_hotkey + + class SettingsDialog: """Einstellungs-Dialog (läuft in eigenem Thread).""" def __init__(self, config: Config, on_save: Callable[[Config], None]): self._config = config self._on_save = on_save + self._cancel_event = threading.Event() def open(self) -> None: """Öffnet den Dialog in einem Daemon-Thread.""" @@ -94,36 +68,28 @@ class SettingsDialog: row=1, column=0, columnspan=3, sticky=tk.W ) - def record_hotkey(): + record_hotkey = _get_record_hotkey() + + def do_record(): hotkey_var.set("...") conflict_var.set("") - captured: list[str] = [] + def on_result(evdev_key: str, has_conflict: bool): + root.after(0, lambda: hotkey_var.set(evdev_key)) + if has_conflict: + root.after( + 0, + lambda: conflict_var.set( + "⚠ Taste ist von einer anderen App belegt (Win32-Hotkeys)" + ), + ) - def on_press(key): - evdev = pynput_to_evdev_key(key) - if evdev: - captured.append(evdev) - return False # Listener stoppen + def worker(): + record_hotkey(on_result, self._cancel_event) - def listen(): - from pynput.keyboard import Listener - with Listener(on_press=on_press) as lst: - lst.join() - if captured: - evdev = captured[0] - root.after(0, lambda: hotkey_var.set(evdev)) - if check_hotkey_conflict(evdev): - root.after( - 0, - lambda: conflict_var.set( - "⚠ Taste ist von einer anderen App belegt (Win32-Hotkeys)" - ), - ) + threading.Thread(target=worker, daemon=True).start() - threading.Thread(target=listen, daemon=True).start() - - ttk.Button(frame, text="Aufzeichnen", command=record_hotkey).grid( + ttk.Button(frame, text="Aufzeichnen", command=do_record).grid( row=0, column=2, padx=4 ) @@ -154,9 +120,15 @@ class SettingsDialog: ) save_config(new_config) self._on_save(new_config) + self._cancel_event.set() + root.destroy() + + def cancel(): + self._cancel_event.set() root.destroy() ttk.Button(btn_frame, text="Speichern", command=save).pack(side=tk.RIGHT, padx=4) - ttk.Button(btn_frame, text="Abbrechen", command=root.destroy).pack(side=tk.RIGHT) + ttk.Button(btn_frame, text="Abbrechen", command=cancel).pack(side=tk.RIGHT) + root.protocol("WM_DELETE_WINDOW", cancel) root.mainloop()