feat: add SettingsDialog with hotkey recording and microphone selection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -182,3 +182,63 @@ class TestPynputToEvdevKey:
|
||||
def test_unknown_returns_empty(self):
|
||||
from whisper_local.tray._settings import pynput_to_evdev_key
|
||||
assert pynput_to_evdev_key(None) == ""
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="Settings nur auf Windows")
|
||||
class TestSettingsDialog:
|
||||
def test_on_save_called_with_new_config(self):
|
||||
import tkinter as tk
|
||||
from unittest.mock import patch, MagicMock, call
|
||||
from whisper_local.config import Config
|
||||
from whisper_local.tray._settings import SettingsDialog
|
||||
|
||||
saved = []
|
||||
dialog = SettingsDialog(
|
||||
config=Config(hotkey="KEY_F12", microphone=""),
|
||||
on_save=saved.append,
|
||||
)
|
||||
|
||||
# Dialog direkt aufrufen (nicht in Thread), mit gemocktem mainloop
|
||||
with patch("tkinter.Tk.mainloop"), \
|
||||
patch("whisper_local.tray._settings.apply_system_theme"), \
|
||||
patch("whisper_local.tray._settings.list_microphones", return_value=[]), \
|
||||
patch("whisper_local.tray._settings.save_config") as mock_save:
|
||||
dialog._run()
|
||||
|
||||
# _run() ruft mainloop auf, der sofort zurückkehrt.
|
||||
# save() wird nicht automatisch aufgerufen — das ist korrekt.
|
||||
# Wir prüfen nur, dass _run() ohne Fehler durchläuft.
|
||||
assert mock_save.call_count == 0 # Noch nicht gespeichert ohne Klick
|
||||
|
||||
def test_on_save_callback_called_when_save_invoked(self):
|
||||
import tkinter as tk
|
||||
from unittest.mock import patch, MagicMock
|
||||
from whisper_local.config import Config
|
||||
from whisper_local.tray._settings import SettingsDialog
|
||||
|
||||
saved_configs = []
|
||||
dialog = SettingsDialog(
|
||||
config=Config(hotkey="KEY_F12", microphone=""),
|
||||
on_save=saved_configs.append,
|
||||
)
|
||||
|
||||
# _run() intern aufrufen und save() direkt triggern
|
||||
captured_save_fn = []
|
||||
|
||||
def fake_button(frame, text, command, **kwargs):
|
||||
if text == "Speichern":
|
||||
captured_save_fn.append(command)
|
||||
mock = MagicMock()
|
||||
mock.pack = MagicMock()
|
||||
return mock
|
||||
|
||||
with patch("tkinter.Tk.mainloop"), \
|
||||
patch("whisper_local.tray._settings.apply_system_theme"), \
|
||||
patch("whisper_local.tray._settings.list_microphones", return_value=[("USB Mic", 0)]), \
|
||||
patch("whisper_local.tray._settings.save_config"), \
|
||||
patch("tkinter.ttk.Button", side_effect=fake_button):
|
||||
dialog._run()
|
||||
|
||||
if captured_save_fn:
|
||||
captured_save_fn[0]()
|
||||
assert len(saved_configs) == 1
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Callable
|
||||
import sounddevice as sd
|
||||
|
||||
from whisper_local.config import Config, save_config
|
||||
from whisper_local.tray._theme import apply_system_theme
|
||||
|
||||
|
||||
def pynput_to_evdev_key(key) -> str:
|
||||
@@ -55,3 +56,107 @@ def list_microphones() -> list[tuple[str, int]]:
|
||||
for idx, dev in enumerate(devices)
|
||||
if dev["max_input_channels"] > 0
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
|
||||
def open(self) -> None:
|
||||
"""Öffnet den Dialog in einem Daemon-Thread."""
|
||||
thread = threading.Thread(target=self._run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _run(self) -> None:
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
root = tk.Tk()
|
||||
root.title("whisper-local – Einstellungen")
|
||||
root.resizable(False, False)
|
||||
apply_system_theme(root)
|
||||
|
||||
frame = ttk.Frame(root, padding=16)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# --- Hotkey ---
|
||||
ttk.Label(frame, text="Hotkey").grid(row=0, column=0, sticky=tk.W, pady=4)
|
||||
hotkey_var = tk.StringVar(value=self._config.hotkey)
|
||||
ttk.Label(frame, textvariable=hotkey_var, width=14, relief="sunken").grid(
|
||||
row=0, column=1, padx=8
|
||||
)
|
||||
|
||||
conflict_var = tk.StringVar()
|
||||
ttk.Label(frame, textvariable=conflict_var, foreground="orange").grid(
|
||||
row=1, column=0, columnspan=3, sticky=tk.W
|
||||
)
|
||||
|
||||
def record_hotkey():
|
||||
hotkey_var.set("...")
|
||||
conflict_var.set("")
|
||||
|
||||
captured: list[str] = []
|
||||
|
||||
def on_press(key):
|
||||
evdev = pynput_to_evdev_key(key)
|
||||
if evdev:
|
||||
captured.append(evdev)
|
||||
return False # Listener stoppen
|
||||
|
||||
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=listen, daemon=True).start()
|
||||
|
||||
ttk.Button(frame, text="Aufzeichnen", command=record_hotkey).grid(
|
||||
row=0, column=2, padx=4
|
||||
)
|
||||
|
||||
# --- Mikrofon ---
|
||||
ttk.Label(frame, text="Mikrofon").grid(row=2, column=0, sticky=tk.W, pady=4)
|
||||
mics = list_microphones()
|
||||
mic_names = ["Standard"] + [name for name, _ in mics]
|
||||
current_mic = self._config.microphone or "Standard"
|
||||
mic_var = tk.StringVar(value=current_mic)
|
||||
ttk.Combobox(
|
||||
frame, textvariable=mic_var, values=mic_names, state="readonly", width=32
|
||||
).grid(row=2, column=1, columnspan=2, sticky=tk.W, padx=4)
|
||||
|
||||
# --- Buttons ---
|
||||
btn_frame = ttk.Frame(frame)
|
||||
btn_frame.grid(row=3, column=0, columnspan=3, pady=12, sticky=tk.E)
|
||||
|
||||
def save():
|
||||
new_config = Config(
|
||||
hotkey=hotkey_var.get(),
|
||||
whisper_model=self._config.whisper_model,
|
||||
language=self._config.language,
|
||||
compute_type=self._config.compute_type,
|
||||
sample_rate=self._config.sample_rate,
|
||||
channels=self._config.channels,
|
||||
min_duration=self._config.min_duration,
|
||||
microphone="" if mic_var.get() == "Standard" else mic_var.get(),
|
||||
)
|
||||
save_config(new_config)
|
||||
self._on_save(new_config)
|
||||
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)
|
||||
|
||||
root.mainloop()
|
||||
|
||||
Reference in New Issue
Block a user