refactor: Windows-Hotkey-Record in eigenes Modul auslagern
This commit is contained in:
+7
-7
@@ -111,7 +111,7 @@ class TestApplySystemTheme:
|
|||||||
class TestCheckHotkeyConflict:
|
class TestCheckHotkeyConflict:
|
||||||
def test_returns_false_when_key_is_free(self):
|
def test_returns_false_when_key_is_free(self):
|
||||||
from unittest.mock import patch, MagicMock
|
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 = MagicMock()
|
||||||
mock_user32.RegisterHotKey.return_value = 1 # Erfolg
|
mock_user32.RegisterHotKey.return_value = 1 # Erfolg
|
||||||
@@ -123,7 +123,7 @@ class TestCheckHotkeyConflict:
|
|||||||
|
|
||||||
def test_returns_true_when_key_is_taken(self):
|
def test_returns_true_when_key_is_taken(self):
|
||||||
from unittest.mock import patch, MagicMock
|
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 = MagicMock()
|
||||||
mock_user32.RegisterHotKey.return_value = 0 # Belegt
|
mock_user32.RegisterHotKey.return_value = 0 # Belegt
|
||||||
@@ -134,7 +134,7 @@ class TestCheckHotkeyConflict:
|
|||||||
mock_user32.UnregisterHotKey.assert_not_called()
|
mock_user32.UnregisterHotKey.assert_not_called()
|
||||||
|
|
||||||
def test_returns_false_for_unknown_key(self):
|
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")
|
result = check_hotkey_conflict("KEY_NONEXISTENT_999")
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
@@ -165,22 +165,22 @@ class TestListMicrophones:
|
|||||||
class TestPynputToEvdevKey:
|
class TestPynputToEvdevKey:
|
||||||
def test_function_key(self):
|
def test_function_key(self):
|
||||||
from pynput.keyboard import Key
|
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"
|
assert pynput_to_evdev_key(Key.f12) == "KEY_F12"
|
||||||
|
|
||||||
def test_space_key(self):
|
def test_space_key(self):
|
||||||
from pynput.keyboard import Key
|
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"
|
assert pynput_to_evdev_key(Key.space) == "KEY_SPACE"
|
||||||
|
|
||||||
def test_char_key(self):
|
def test_char_key(self):
|
||||||
from pynput.keyboard import KeyCode
|
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")
|
key = KeyCode.from_char("a")
|
||||||
assert pynput_to_evdev_key(key) == "KEY_A"
|
assert pynput_to_evdev_key(key) == "KEY_A"
|
||||||
|
|
||||||
def test_unknown_returns_empty(self):
|
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) == ""
|
assert pynput_to_evdev_key(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Einstellungs-Dialog für whisper-local (Windows)."""
|
"""Einstellungs-Dialog für whisper-local (cross-platform)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ctypes
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from typing import Callable
|
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
|
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]]:
|
def list_microphones() -> list[tuple[str, int]]:
|
||||||
"""Gibt Liste aller Eingabegeräte als (name, index) zurück."""
|
"""Gibt Liste aller Eingabegeräte als (name, index) zurück."""
|
||||||
devices = sd.query_devices()
|
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:
|
class SettingsDialog:
|
||||||
"""Einstellungs-Dialog (läuft in eigenem Thread)."""
|
"""Einstellungs-Dialog (läuft in eigenem Thread)."""
|
||||||
|
|
||||||
def __init__(self, config: Config, on_save: Callable[[Config], None]):
|
def __init__(self, config: Config, on_save: Callable[[Config], None]):
|
||||||
self._config = config
|
self._config = config
|
||||||
self._on_save = on_save
|
self._on_save = on_save
|
||||||
|
self._cancel_event = threading.Event()
|
||||||
|
|
||||||
def open(self) -> None:
|
def open(self) -> None:
|
||||||
"""Öffnet den Dialog in einem Daemon-Thread."""
|
"""Öffnet den Dialog in einem Daemon-Thread."""
|
||||||
@@ -94,36 +68,28 @@ class SettingsDialog:
|
|||||||
row=1, column=0, columnspan=3, sticky=tk.W
|
row=1, column=0, columnspan=3, sticky=tk.W
|
||||||
)
|
)
|
||||||
|
|
||||||
def record_hotkey():
|
record_hotkey = _get_record_hotkey()
|
||||||
|
|
||||||
|
def do_record():
|
||||||
hotkey_var.set("...")
|
hotkey_var.set("...")
|
||||||
conflict_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):
|
def worker():
|
||||||
evdev = pynput_to_evdev_key(key)
|
record_hotkey(on_result, self._cancel_event)
|
||||||
if evdev:
|
|
||||||
captured.append(evdev)
|
|
||||||
return False # Listener stoppen
|
|
||||||
|
|
||||||
def listen():
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
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=listen, daemon=True).start()
|
ttk.Button(frame, text="Aufzeichnen", command=do_record).grid(
|
||||||
|
|
||||||
ttk.Button(frame, text="Aufzeichnen", command=record_hotkey).grid(
|
|
||||||
row=0, column=2, padx=4
|
row=0, column=2, padx=4
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -154,9 +120,15 @@ class SettingsDialog:
|
|||||||
)
|
)
|
||||||
save_config(new_config)
|
save_config(new_config)
|
||||||
self._on_save(new_config)
|
self._on_save(new_config)
|
||||||
|
self._cancel_event.set()
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
|
def cancel():
|
||||||
|
self._cancel_event.set()
|
||||||
root.destroy()
|
root.destroy()
|
||||||
|
|
||||||
ttk.Button(btn_frame, text="Speichern", command=save).pack(side=tk.RIGHT, padx=4)
|
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()
|
root.mainloop()
|
||||||
|
|||||||
Reference in New Issue
Block a user